switching

This commit is contained in:
2025-07-06 13:11:19 +02:00
parent f84b515523
commit ec8786ab9b
38 changed files with 9935 additions and 3539 deletions

4
.eslintignore Normal file
View File

@ -0,0 +1,4 @@
node_modules
dist
*.js
public

38
.eslintrc.json Normal file
View File

@ -0,0 +1,38 @@
{
"env": {
"browser": true,
"es2020": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"prettier"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": "latest",
"sourceType": "module",
"project": "./tsconfig.json"
},
"plugins": [
"react",
"@typescript-eslint"
],
"rules": {
"react/react-in-jsx-scope": "off",
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
"@typescript-eslint/no-explicit-any": "warn",
"prefer-const": "error",
"no-var": "error"
},
"settings": {
"react": {
"version": "detect"
}
}
}

8
.prettierrc Normal file
View File

@ -0,0 +1,8 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 80,
"tabWidth": 2,
"useTabs": false
}

View File

@ -25,6 +25,7 @@ npm run dev
- **H** - Toggle minimal UI mode
- **F11** - Fullscreen
- **R** - Random shader
- **M** - Cycle value modes
- **S** - Share URL
### Shader Variables

View File

@ -4,3 +4,4 @@ x**10*y^200*t+20
x**10*y^200*t+20
x ^ Math.sin(y ^ x) * Math.sin(t) * Math.PI * 200
x ^ Math.sin(y ^ x) * Math.sin(t) * Math.PI ** 4 | x % 40
(x | y + 20 + y*8 * bassLevel * t * audioLevel) + ([x>y, x<y, x&y, x|y][floor(t) * 2 % 3] ? max(y *t, sin(x * t)) : max(x ** y, cos(y * t)))

1026
index.html

File diff suppressed because it is too large Load Diff

4769
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,7 +9,8 @@
"build": "tsc && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"lint": "echo 'No linter configured'"
"lint": "eslint src --ext .ts,.tsx --fix && prettier --write src",
"lint:check": "eslint src --ext .ts,.tsx && prettier --check src"
},
"keywords": [
"shader",
@ -35,7 +36,15 @@
"node": ">=16.0.0"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"eslint": "^8.0.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^4.6.2",
"prettier": "^3.0.0",
"typescript": "^5.0.0",
"typescript-eslint": "^8.35.1",
"vite": "^4.0.0"
},
"files": [
@ -44,6 +53,13 @@
"LICENSE"
],
"dependencies": {
"lucide": "^0.525.0"
"@nanostores/react": "^1.0.0",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"lucide": "^0.525.0",
"nanostores": "^1.0.1",
"react": "^19.1.0",
"react-dom": "^19.1.0"
}
}

View File

@ -1,50 +1,196 @@
const CACHE_NAME = 'bitfielder-v1';
const urlsToCache = [
const CACHE_NAME = 'bitfielder-v2';
const STATIC_CACHE = 'bitfielder-static-v2';
const DYNAMIC_CACHE = 'bitfielder-dynamic-v2';
// Core app files that should always be cached
const CORE_ASSETS = [
'/',
'/index.html',
'/src/main.ts',
'/src/FakeShader.ts',
'/src/ShaderWorker.ts',
'/src/Storage.ts',
'/src/icons.ts',
'/manifest.json'
'/manifest.json',
'/icon-192.png',
'/icon-512.png'
];
// Install event - cache resources
// Assets that can be cached dynamically
const DYNAMIC_ASSETS_PATTERNS = [
/\/src\/.+\.(ts|tsx|js|jsx)$/,
/\/src\/.+\.css$/,
/fonts\.googleapis\.com/,
/fonts\.gstatic\.com/
];
// Install event - cache core resources
self.addEventListener('install', event => {
console.log('Service Worker installing...');
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
console.log('Opened cache');
return cache.addAll(urlsToCache);
Promise.all([
caches.open(STATIC_CACHE).then(cache => {
console.log('Caching core assets');
return cache.addAll(CORE_ASSETS);
}),
caches.open(DYNAMIC_CACHE).then(cache => {
console.log('Dynamic cache initialized');
})
]).then(() => {
console.log('Service Worker installed successfully');
return self.skipWaiting();
})
);
});
// Fetch event - serve from cache when offline
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(response => {
// Return cached version or fetch from network
return response || fetch(event.request);
}
)
);
});
// Activate event - clean up old caches
// Activate event - clean up old caches and take control
self.addEventListener('activate', event => {
console.log('Service Worker activating...');
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
if (cacheName !== CACHE_NAME) {
return Promise.all([
...cacheNames.map(cacheName => {
if (cacheName !== STATIC_CACHE && cacheName !== DYNAMIC_CACHE) {
console.log('Deleting old cache:', cacheName);
return caches.delete(cacheName);
}
})
);
}),
self.clients.claim()
]);
}).then(() => {
console.log('Service Worker activated successfully');
})
);
});
// Fetch event - sophisticated caching strategy
self.addEventListener('fetch', event => {
const { request } = event;
const url = new URL(request.url);
// Skip non-GET requests
if (request.method !== 'GET') {
return;
}
// Skip external APIs and chrome-extension
if (!url.origin.includes(self.location.origin) && !url.hostname.includes('googleapis') && !url.hostname.includes('gstatic')) {
return;
}
event.respondWith(handleFetch(request));
});
async function handleFetch(request) {
const url = new URL(request.url);
try {
// Strategy 1: Core assets - Cache First
if (CORE_ASSETS.some(asset => url.pathname === asset || url.pathname.endsWith(asset))) {
return await cacheFirst(request, STATIC_CACHE);
}
// Strategy 2: Dynamic assets - Stale While Revalidate
if (DYNAMIC_ASSETS_PATTERNS.some(pattern => pattern.test(url.pathname))) {
return await staleWhileRevalidate(request, DYNAMIC_CACHE);
}
// Strategy 3: Fonts - Cache First with longer TTL
if (url.hostname.includes('googleapis') || url.hostname.includes('gstatic')) {
return await cacheFirst(request, DYNAMIC_CACHE);
}
// Strategy 4: Everything else - Network First
return await networkFirst(request, DYNAMIC_CACHE);
} catch (error) {
console.error('Fetch error:', error);
// Fallback for navigation requests
if (request.mode === 'navigate') {
const cache = await caches.open(STATIC_CACHE);
return await cache.match('/index.html') || new Response('App offline', { status: 503 });
}
return new Response('Resource not available offline', { status: 503 });
}
}
// Cache First strategy
async function cacheFirst(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
if (cached) {
return cached;
}
const response = await fetch(request);
if (response.status === 200) {
cache.put(request, response.clone());
}
return response;
}
// Network First strategy
async function networkFirst(request, cacheName) {
const cache = await caches.open(cacheName);
try {
const response = await fetch(request);
if (response.status === 200) {
cache.put(request, response.clone());
}
return response;
} catch (error) {
const cached = await cache.match(request);
if (cached) {
return cached;
}
throw error;
}
}
// Stale While Revalidate strategy
async function staleWhileRevalidate(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
// Always try to fetch and update cache in background
const fetchPromise = fetch(request).then(response => {
if (response.status === 200) {
cache.put(request, response.clone());
}
return response;
}).catch(() => null);
// Return cached version immediately if available, otherwise wait for network
return cached || await fetchPromise;
}
// Handle background sync for offline actions
self.addEventListener('sync', event => {
console.log('Background sync triggered:', event.tag);
if (event.tag === 'shader-save') {
event.waitUntil(syncShaderData());
}
});
async function syncShaderData() {
// This would handle syncing shader data when coming back online
// For now, just log that sync is available
console.log('Shader data sync available for future implementation');
}
// Handle push notifications (for future features)
self.addEventListener('push', event => {
if (event.data) {
const data = event.data.json();
console.log('Push notification received:', data);
// Could be used for shader sharing notifications, etc.
}
});
// Provide offline status
self.addEventListener('message', event => {
if (event.data && event.data.type === 'GET_VERSION') {
event.ports[0].postMessage({ version: CACHE_NAME });
}
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,177 +1,180 @@
interface SavedShader {
id: string;
name: string;
code: string;
created: number;
lastUsed: number;
// Visual settings
resolution?: number;
fps?: number;
renderMode?: string;
valueMode?: string;
uiOpacity?: number;
}
import { AppSettings } from './stores/appSettings';
import { STORAGE_KEYS, PERFORMANCE, DEFAULTS, ValueMode } from './utils/constants';
interface AppSettings {
resolution: number;
fps: number;
lastShaderCode: string;
renderMode: string;
valueMode?: string;
uiOpacity?: number;
export interface SavedShader {
id: string;
name: string;
code: string;
created: number;
lastUsed: number;
// Visual settings
resolution?: number;
fps?: number;
renderMode?: string;
valueMode?: ValueMode;
uiOpacity?: number;
}
export class Storage {
private static readonly SHADERS_KEY = 'bitfielder_shaders';
private static readonly SETTINGS_KEY = 'bitfielder_settings';
static saveShader(name: string, code: string, settings?: Partial<AppSettings>): SavedShader {
const shaders = this.getShaders();
const id = this.generateId();
const timestamp = Date.now();
const shader: SavedShader = {
id,
name: name.trim() || `Shader ${shaders.length + 1}`,
code,
created: timestamp,
lastUsed: timestamp,
// Include settings if provided
...(settings && {
resolution: settings.resolution,
fps: settings.fps,
renderMode: settings.renderMode,
valueMode: settings.valueMode,
uiOpacity: settings.uiOpacity
})
};
shaders.push(shader);
this.setShaders(shaders);
return shader;
private static readonly SHADERS_KEY = STORAGE_KEYS.SHADERS;
private static readonly SETTINGS_KEY = STORAGE_KEYS.SETTINGS;
static saveShader(
name: string,
code: string,
settings?: Partial<AppSettings>
): SavedShader {
const shaders = this.getShaders();
const id = this.generateId();
const timestamp = Date.now();
const shader: SavedShader = {
id,
name: name.trim() || `Shader ${shaders.length + 1}`,
code,
created: timestamp,
lastUsed: timestamp,
// Include settings if provided
...(settings && {
resolution: settings.resolution,
fps: settings.fps,
renderMode: settings.renderMode,
valueMode: settings.valueMode,
uiOpacity: settings.uiOpacity,
}),
};
shaders.push(shader);
this.setShaders(shaders);
return shader;
}
static getShaders(): SavedShader[] {
try {
const stored = localStorage.getItem(this.SHADERS_KEY);
return stored ? JSON.parse(stored) : [];
} catch (error) {
console.error('Failed to load shaders:', error);
return [];
}
static getShaders(): SavedShader[] {
try {
const stored = localStorage.getItem(this.SHADERS_KEY);
return stored ? JSON.parse(stored) : [];
} catch (error) {
console.error('Failed to load shaders:', error);
return [];
}
}
static deleteShader(id: string): void {
const shaders = this.getShaders().filter((s) => s.id !== id);
this.setShaders(shaders);
}
static updateShaderUsage(id: string): void {
const shaders = this.getShaders();
const shader = shaders.find((s) => s.id === id);
if (shader) {
shader.lastUsed = Date.now();
this.setShaders(shaders);
}
static deleteShader(id: string): void {
const shaders = this.getShaders().filter(s => s.id !== id);
this.setShaders(shaders);
}
static renameShader(id: string, newName: string): void {
const shaders = this.getShaders();
const shader = shaders.find((s) => s.id === id);
if (shader) {
shader.name = newName.trim() || shader.name;
this.setShaders(shaders);
}
static updateShaderUsage(id: string): void {
const shaders = this.getShaders();
const shader = shaders.find(s => s.id === id);
if (shader) {
shader.lastUsed = Date.now();
this.setShaders(shaders);
}
}
private static setShaders(shaders: SavedShader[]): void {
try {
// Keep only the most recent shaders
const sortedShaders = shaders
.sort((a, b) => b.lastUsed - a.lastUsed)
.slice(0, PERFORMANCE.MAX_SAVED_SHADERS);
localStorage.setItem(this.SHADERS_KEY, JSON.stringify(sortedShaders));
} catch (error) {
console.error('Failed to save shaders:', error);
}
static renameShader(id: string, newName: string): void {
const shaders = this.getShaders();
const shader = shaders.find(s => s.id === id);
if (shader) {
shader.name = newName.trim() || shader.name;
this.setShaders(shaders);
}
}
static saveSettings(settings: Partial<AppSettings>): void {
try {
const current = this.getSettings();
const updated = { ...current, ...settings };
localStorage.setItem(this.SETTINGS_KEY, JSON.stringify(updated));
} catch (error) {
console.error('Failed to save settings:', error);
}
private static setShaders(shaders: SavedShader[]): void {
try {
// Keep only the 50 most recent shaders
const sortedShaders = shaders
.sort((a, b) => b.lastUsed - a.lastUsed)
.slice(0, 50);
localStorage.setItem(this.SHADERS_KEY, JSON.stringify(sortedShaders));
} catch (error) {
console.error('Failed to save shaders:', error);
}
}
static getSettings(): AppSettings {
try {
const stored = localStorage.getItem(this.SETTINGS_KEY);
const defaults: AppSettings = {
resolution: DEFAULTS.RESOLUTION,
fps: DEFAULTS.FPS,
lastShaderCode: DEFAULTS.SHADER_CODE,
renderMode: DEFAULTS.RENDER_MODE,
valueMode: DEFAULTS.VALUE_MODE,
uiOpacity: DEFAULTS.UI_OPACITY,
};
return stored ? { ...defaults, ...JSON.parse(stored) } : defaults;
} catch (error) {
console.error('Failed to load settings:', error);
return {
resolution: DEFAULTS.RESOLUTION,
fps: DEFAULTS.FPS,
lastShaderCode: DEFAULTS.SHADER_CODE,
renderMode: DEFAULTS.RENDER_MODE,
valueMode: DEFAULTS.VALUE_MODE,
uiOpacity: DEFAULTS.UI_OPACITY,
};
}
static saveSettings(settings: Partial<AppSettings>): void {
try {
const current = this.getSettings();
const updated = { ...current, ...settings };
localStorage.setItem(this.SETTINGS_KEY, JSON.stringify(updated));
} catch (error) {
console.error('Failed to save settings:', error);
}
}
static clearAll(): void {
try {
localStorage.removeItem(this.SHADERS_KEY);
localStorage.removeItem(this.SETTINGS_KEY);
} catch (error) {
console.error('Failed to clear storage:', error);
}
static getSettings(): AppSettings {
try {
const stored = localStorage.getItem(this.SETTINGS_KEY);
const defaults: AppSettings = {
resolution: 1,
fps: 30,
lastShaderCode: 'x^y',
renderMode: 'classic',
uiOpacity: 0.3
};
return stored ? { ...defaults, ...JSON.parse(stored) } : defaults;
} catch (error) {
console.error('Failed to load settings:', error);
return {
resolution: 1,
fps: 30,
lastShaderCode: 'x^y',
renderMode: 'classic',
uiOpacity: 0.3
};
}
}
private static generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).substr(2);
}
static exportShaders(): string {
const shaders = this.getShaders();
return JSON.stringify(shaders, null, 2);
}
static importShaders(jsonData: string): boolean {
try {
const imported = JSON.parse(jsonData) as SavedShader[];
if (!Array.isArray(imported)) {
return false;
}
// Validate structure
const valid = imported.every(
(shader) =>
shader.id &&
shader.name &&
shader.code &&
typeof shader.created === 'number' &&
typeof shader.lastUsed === 'number'
);
if (!valid) {
return false;
}
const existing = this.getShaders();
const merged = [...existing, ...imported];
this.setShaders(merged);
return true;
} catch (error) {
console.error('Failed to import shaders:', error);
return false;
}
static clearAll(): void {
try {
localStorage.removeItem(this.SHADERS_KEY);
localStorage.removeItem(this.SETTINGS_KEY);
} catch (error) {
console.error('Failed to clear storage:', error);
}
}
private static generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).substr(2);
}
static exportShaders(): string {
const shaders = this.getShaders();
return JSON.stringify(shaders, null, 2);
}
static importShaders(jsonData: string): boolean {
try {
const imported = JSON.parse(jsonData) as SavedShader[];
if (!Array.isArray(imported)) {
return false;
}
// Validate structure
const valid = imported.every(shader =>
shader.id && shader.name && shader.code &&
typeof shader.created === 'number' &&
typeof shader.lastUsed === 'number'
);
if (!valid) {
return false;
}
const existing = this.getShaders();
const merged = [...existing, ...imported];
this.setShaders(merged);
return true;
} catch (error) {
console.error('Failed to import shaders:', error);
return false;
}
}
}
}
}

87
src/components/App.tsx Normal file
View File

@ -0,0 +1,87 @@
import { useEffect } from 'react';
import { useStore } from '@nanostores/react';
import { TopBar } from './TopBar';
import { MobileMenu } from './MobileMenu';
import { EditorPanel } from './EditorPanel';
import { ShaderLibrary } from './ShaderLibrary';
import { HelpPopup } from './HelpPopup';
import { WelcomePopup } from './WelcomePopup';
import { ShaderCanvas } from './ShaderCanvas';
import { PerformanceWarning } from './PerformanceWarning';
import { uiState, showUI } from '../stores/ui';
import { $appSettings } from '../stores/appSettings';
import { $shader } from '../stores/shader';
import { loadShaders } from '../stores/library';
import { Storage } from '../Storage';
import { LucideIcon } from '../hooks/useLucideIcon';
export function App() {
const ui = useStore(uiState);
const settings = useStore($appSettings);
const shader = useStore($shader);
useEffect(() => {
// Load initial settings from storage
const savedSettings = Storage.getSettings();
$appSettings.set(savedSettings);
// Load saved shaders
loadShaders();
// Set CSS custom property for UI opacity
document.documentElement.style.setProperty(
'--ui-opacity',
(settings.uiOpacity ?? 0.3).toString()
);
}, []);
useEffect(() => {
// Update CSS custom property when opacity changes
document.documentElement.style.setProperty(
'--ui-opacity',
(settings.uiOpacity ?? 0.3).toString()
);
}, [settings.uiOpacity]);
// Save settings changes to localStorage
useEffect(() => {
Storage.saveSettings({
resolution: settings.resolution,
fps: settings.fps,
renderMode: settings.renderMode,
valueMode: settings.valueMode,
uiOpacity: settings.uiOpacity,
lastShaderCode: shader.code,
});
}, [settings, shader.code]);
return (
<>
<ShaderCanvas />
{ui.uiVisible ? (
<>
<TopBar />
{!ui.mobileMenuOpen && <EditorPanel />}
</>
) : (
<>
<button
id="show-ui-btn"
onClick={showUI}
style={{ display: 'block' }}
>
<LucideIcon name="show" />
</button>
<EditorPanel minimal={true} />
</>
)}
<MobileMenu />
<ShaderLibrary />
<HelpPopup />
<WelcomePopup />
<PerformanceWarning />
</>
);
}

View File

@ -0,0 +1,59 @@
import { useState, useEffect } from 'react';
import { useStore } from '@nanostores/react';
import { $shader, setShaderCode } from '../stores/shader';
interface EditorPanelProps {
minimal?: boolean;
}
export function EditorPanel({ minimal = false }: EditorPanelProps) {
const shader = useStore($shader);
// const ui = useStore(uiState); // Unused for now
const [localCode, setLocalCode] = useState(shader.code);
// Check if code has changed from the compiled version
const hasChanges = localCode !== shader.code;
const handleCodeChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
// Only update local state, don't compile until eval
setLocalCode(e.target.value);
};
// Sync local code when shader code changes externally (e.g., from library)
useEffect(() => {
setLocalCode(shader.code);
}, [shader.code]);
const handleEval = () => {
// Compile and render the shader
setShaderCode(localCode);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
handleEval();
}
};
return (
<div id="editor-panel" className={minimal ? 'minimal' : ''}>
<textarea
id="editor"
className={minimal ? 'minimal' : ''}
value={localCode}
onChange={handleCodeChange}
onKeyDown={handleKeyDown}
placeholder="Enter shader code... (x, y, t, i, mouseX, mouseY, mousePressed, touchCount, accelX, audioLevel, bassLevel...)"
spellCheck={false}
/>
<button
id="eval-btn"
className={minimal ? 'minimal' : ''}
onClick={handleEval}
>
{hasChanges ? 'Eval *' : 'Eval'}
</button>
</div>
);
}

View File

@ -0,0 +1,296 @@
import React from 'react';
import { useStore } from '@nanostores/react';
import { uiState, hideHelp } from '../stores/ui';
export function HelpPopup() {
const ui = useStore(uiState);
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
hideHelp();
}
};
if (!ui.helpPopupOpen) return null;
return (
<div
id="help-popup"
style={{ display: 'block' }}
onClick={handleBackdropClick}
>
<button className="close-btn" onClick={hideHelp}>
&times;
</button>
<h3>Bitfielder Help</h3>
<div className="help-content">
<div className="help-section">
<h4>Keyboard Shortcuts</h4>
<p>
<strong>Ctrl+Enter</strong> - Execute shader code
</p>
<p>
<strong>F11</strong> - Toggle fullscreen
</p>
<p>
<strong>H</strong> - Hide/show UI
</p>
<p>
<strong>R</strong> - Generate random shader
</p>
<p>
<strong>S</strong> - Share current shader (copy URL)
</p>
<p>
<strong>?</strong> - Show this help
</p>
</div>
<div className="help-section">
<h4>Variables</h4>
<p>
<strong>x, y</strong> - Pixel coordinates
</p>
<p>
<strong>t</strong> - Time (enables animation)
</p>
<p>
<strong>i</strong> - Pixel index
</p>
<p>
<strong>mouseX, mouseY</strong> - Mouse position (0.0 to 1.0)
</p>
<p>
<strong>mousePressed</strong> - Mouse button down (true/false)
</p>
<p>
<strong>mouseVX, mouseVY</strong> - Mouse velocity
</p>
<p>
<strong>mouseClickTime</strong> - Time since last click (ms)
</p>
</div>
<div className="help-section">
<h4>Touch & Gestures</h4>
<p>
<strong>touchCount</strong> - Number of active touches
</p>
<p>
<strong>touch0X, touch0Y</strong> - Primary touch position
</p>
<p>
<strong>touch1X, touch1Y</strong> - Secondary touch position
</p>
<p>
<strong>pinchScale</strong> - Pinch zoom scale factor
</p>
<p>
<strong>pinchRotation</strong> - Pinch rotation angle
</p>
</div>
<div className="help-section">
<h4>Device Motion</h4>
<p>
<strong>accelX, accelY, accelZ</strong> - Accelerometer data
</p>
<p>
<strong>gyroX, gyroY, gyroZ</strong> - Gyroscope rotation rates
</p>
</div>
<div className="help-section">
<h4>Audio Reactive</h4>
<p>
<strong>audioLevel</strong> - Overall audio volume (0.0-1.0)
</p>
<p>
<strong>bassLevel</strong> - Low frequencies (0.0-1.0)
</p>
<p>
<strong>midLevel</strong> - Mid frequencies (0.0-1.0)
</p>
<p>
<strong>trebleLevel</strong> - High frequencies (0.0-1.0)
</p>
<p>Click "Enable Audio" to activate microphone</p>
</div>
<div className="help-section">
<h4>Operators</h4>
<p>
<strong>^ & |</strong> - XOR, AND, OR
</p>
<p>
<strong>&lt;&lt; &gt;&gt;</strong> - Bit shift left/right
</p>
<p>
<strong>+ - * / %</strong> - Math operations
</p>
<p>
<strong>== != &lt; &gt;</strong> - Comparisons (return 0/1)
</p>
<p>
<strong>? :</strong> - Ternary operator (condition ? true : false)
</p>
<p>
<strong>~ **</strong> - Bitwise NOT, exponentiation
</p>
</div>
<div className="help-section">
<h4>Math Functions</h4>
<p>
<strong>sin, cos, tan</strong> - Trigonometric functions
</p>
<p>
<strong>abs, sqrt, pow</strong> - Absolute, square root, power
</p>
<p>
<strong>floor, ceil, round</strong> - Rounding functions
</p>
<p>
<strong>min, max</strong> - Minimum and maximum
</p>
<p>
<strong>random</strong> - Random number 0-1
</p>
<p>
<strong>log, exp</strong> - Natural logarithm, exponential
</p>
<p>
<strong>PI, E</strong> - Math constants
</p>
<p>
Use without Math. prefix: <code>sin(x)</code> not{' '}
<code>Math.sin(x)</code>
</p>
</div>
<div className="help-section">
<h4>Value Modes</h4>
<p>
<strong>Integer (0-255):</strong> Traditional mode for large values
</p>
<p>
<strong>Float (0.0-1.0):</strong> Bitfield shader mode, inverts and
clamps values
</p>
<p>
<strong>Polar (angle-based):</strong> Spiral patterns combining
angle and radius
</p>
<p>
<strong>Distance (radial):</strong> Concentric wave rings with
variable frequency
</p>
<p>
<strong>Wave (ripple):</strong> Multi-source interference with
amplitude falloff
</p>
<p>Each mode transforms your expression differently!</p>
</div>
<div className="help-section">
<h4>Advanced Features</h4>
<p>
<strong>Array indexing:</strong> <code>[1,2,4,8][floor(t%4)]</code>
</p>
<p>
<strong>Complex expressions:</strong>{' '}
<code>x&gt;y ? sin(x) : cos(y)</code>
</p>
<p>
<strong>Nested functions:</strong>{' '}
<code>pow(sin(x), abs(y-x))</code>
</p>
<p>
<strong>Logical operators:</strong> <code>x&amp;&amp;y</code>,{' '}
<code>x||y</code>
</p>
<p>No character or length limits - use any JavaScript!</p>
</div>
<div className="help-section">
<h4>Shader Library</h4>
<p>
Hover over the <strong>left edge</strong> of the screen to access
the shader library
</p>
<p>Save shaders with custom names and search through them</p>
<p>
Use <strong>edit</strong> to rename, <strong>del</strong> to delete
</p>
</div>
<div className="help-section">
<h4>Render Modes</h4>
<p>
<strong>Classic</strong> - Original colorful mode
</p>
<p>
<strong>Grayscale</strong> - Black and white
</p>
<p>
<strong>Red/Green/Blue</strong> - Single color channels
</p>
<p>
<strong>HSV</strong> - Hue-based coloring
</p>
<p>
<strong>Rainbow</strong> - Spectrum coloring
</p>
</div>
<div className="help-section">
<h4>Export</h4>
<p>
<strong>Export PNG</strong> - Save current frame as image
</p>
</div>
</div>
<div
className="help-section"
style={{
gridColumn: '1 / -1',
marginTop: '20px',
textAlign: 'center',
paddingTop: '20px',
borderBottom: 'none',
}}
>
<h4>About</h4>
<p>
<strong>Bitfielder</strong> - Interactive bitfield shader editor
</p>
<p>
Created by <strong>BuboBubo</strong> (Raphaël Forment)
</p>
<p>
Website:{' '}
<a
href="https://raphaelforment.fr"
target="_blank"
>
raphaelforment.fr
</a>
</p>
<p>
Source:{' '}
<a
href="https://git.raphaelforment.fr"
target="_blank"
>
git.raphaelforment.fr
</a>
</p>
<p>
License: <strong>AGPL 3.0</strong>
</p>
</div>
</div>
);
}

View File

@ -0,0 +1,191 @@
import { useStore } from '@nanostores/react';
import { uiState, closeMobileMenu, showHelp } from '../stores/ui';
import { $appSettings, updateAppSettings } from '../stores/appSettings';
import { VALUE_MODES, ValueMode } from '../utils/constants';
import { $input } from '../stores/input';
import { LucideIcon } from '../hooks/useLucideIcon';
import { useAudio } from '../hooks/useAudio';
function getValueModeLabel(mode: string): string {
const labels: Record<string, string> = {
integer: 'Integer (0-255)',
float: 'Float (0.0-1.0)',
polar: 'Polar (angle-based)',
distance: 'Distance (radial)',
wave: 'Wave (ripple)',
fractal: 'Fractal (recursive)',
cellular: 'Cellular (automata)',
noise: 'Noise (perlin-like)',
warp: 'Warp (space deformation)',
flow: 'Flow (fluid dynamics)',
};
return labels[mode] || mode;
}
export function MobileMenu() {
const ui = useStore(uiState);
const settings = useStore($appSettings);
const input = useStore($input);
const { setupAudio, disableAudio } = useAudio();
const handleFullscreen = () => {
closeMobileMenu();
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
} else {
document.exitFullscreen();
}
};
const handleShare = () => {
closeMobileMenu();
// Implement share functionality
};
const handleExportPNG = () => {
closeMobileMenu();
const canvas = document.getElementById('canvas') as HTMLCanvasElement;
if (canvas) {
const link = document.createElement('a');
link.download = `bitfielder-${Date.now()}.png`;
link.href = canvas.toDataURL('image/png');
link.click();
}
};
const handleAudioToggle = async () => {
closeMobileMenu();
if (input.audioEnabled) {
disableAudio();
} else {
await setupAudio();
}
};
const handleHelp = () => {
closeMobileMenu();
showHelp();
};
return (
<>
<div
id="mobile-menu-overlay"
className={ui.mobileMenuOpen ? 'open' : ''}
onClick={closeMobileMenu}
/>
<div id="mobile-menu" className={ui.mobileMenuOpen ? 'open' : ''}>
<div className="mobile-menu-section">
<div className="mobile-menu-item">
<label>Resolution</label>
<select
value={settings.resolution}
onChange={(e) =>
updateAppSettings({ resolution: parseInt(e.target.value) })
}
>
<option value="1">Full (1x)</option>
<option value="2">Half (2x)</option>
<option value="4">Quarter (4x)</option>
<option value="8">Eighth (8x)</option>
<option value="16">Sixteenth (16x)</option>
<option value="32">Thirty-second (32x)</option>
</select>
</div>
<div className="mobile-menu-item">
<label>FPS</label>
<select
value={settings.fps}
onChange={(e) =>
updateAppSettings({ fps: parseInt(e.target.value) })
}
>
<option value="15">15 FPS</option>
<option value="30">30 FPS</option>
<option value="60">60 FPS</option>
</select>
</div>
<div className="mobile-menu-item">
<label>Value Mode</label>
<select
value={settings.valueMode}
onChange={(e) => updateAppSettings({ valueMode: e.target.value as ValueMode })}
>
{VALUE_MODES.map((mode) => (
<option key={mode} value={mode}>
{getValueModeLabel(mode)}
</option>
))}
</select>
</div>
<div className="mobile-menu-item">
<label>Render Mode</label>
<select
value={settings.renderMode}
onChange={(e) =>
updateAppSettings({ renderMode: e.target.value })
}
>
<option value="classic">Classic</option>
<option value="grayscale">Grayscale</option>
<option value="red">Red Channel</option>
<option value="green">Green Channel</option>
<option value="blue">Blue Channel</option>
<option value="rgb">RGB Split</option>
<option value="hsv">HSV</option>
<option value="rainbow">Rainbow</option>
<option value="thermal">Thermal</option>
<option value="neon">Neon</option>
<option value="cyberpunk">Cyberpunk</option>
<option value="vaporwave">Vaporwave</option>
<option value="dithered">Dithered</option>
<option value="palette">Palette</option>
</select>
</div>
<div className="mobile-menu-item">
<label>
UI Opacity: {Math.round((settings.uiOpacity ?? 0.3) * 100)}%
</label>
<input
type="range"
min="10"
max="100"
value={Math.round((settings.uiOpacity ?? 0.3) * 100)}
onChange={(e) =>
updateAppSettings({ uiOpacity: parseInt(e.target.value) / 100 })
}
/>
</div>
</div>
<div className="mobile-menu-section">
<div className="mobile-menu-buttons">
<button onClick={handleHelp}>
<LucideIcon name="help" /> Help
</button>
<button onClick={handleFullscreen}>
<LucideIcon name="fullscreen" /> Fullscreen
</button>
<button onClick={handleAudioToggle}>
<LucideIcon
name={input.audioEnabled ? 'microphone' : 'microphone-off'}
/>
{input.audioEnabled ? 'Disable Audio' : 'Enable Audio'}
</button>
<button onClick={handleShare}>
<LucideIcon name="share" /> Share
</button>
<button onClick={handleExportPNG}>
<LucideIcon name="export" /> Export PNG
</button>
</div>
</div>
</div>
</>
);
}

View File

@ -0,0 +1,14 @@
import { useStore } from '@nanostores/react';
import { uiState } from '../stores/ui';
export function PerformanceWarning() {
const ui = useStore(uiState);
if (!ui.performanceWarningVisible) return null;
return (
<div id="performance-warning" style={{ display: 'block' }}>
Performance warning: Shader taking too long to render!
</div>
);
}

View File

@ -0,0 +1,327 @@
import { useRef, useEffect } from 'react';
import { useStore } from '@nanostores/react';
import { $appSettings } from '../stores/appSettings';
import { $shader } from '../stores/shader';
import { uiState, showPerformanceWarning } from '../stores/ui';
import {
$input,
updateMousePosition,
updateTouchPosition,
updateDeviceMotion,
} from '../stores/input';
import { FakeShader } from '../FakeShader';
import { UI_HEIGHTS } from '../utils/constants';
export function ShaderCanvas() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const shaderRef = useRef<FakeShader | null>(null);
const settings = useStore($appSettings);
const shader = useStore($shader);
const ui = useStore(uiState);
const input = useStore($input);
// Mouse tracking state
const mouseState = useRef({
lastX: 0,
lastY: 0,
startTime: Date.now(),
});
// Touch gesture state
const touchState = useRef({
initialPinchDistance: 0,
initialPinchAngle: 0,
});
useEffect(() => {
if (!canvasRef.current) return;
// Initialize shader
shaderRef.current = new FakeShader(canvasRef.current, shader.code);
// Set up canvas size
setupCanvas();
// Clean up on unmount
return () => {
if (shaderRef.current) {
shaderRef.current.destroy();
}
};
}, []);
// Update shader when code changes
useEffect(() => {
if (shaderRef.current) {
shaderRef.current.setCode(shader.code);
}
}, [shader.code]);
// Update shader settings
useEffect(() => {
if (shaderRef.current) {
shaderRef.current.setRenderMode(settings.renderMode);
shaderRef.current.setValueMode(settings.valueMode ?? 'integer');
shaderRef.current.setTargetFPS(settings.fps);
}
}, [settings.renderMode, settings.valueMode, settings.fps]);
// Handle canvas resize when resolution or UI visibility changes
useEffect(() => {
setupCanvas();
}, [settings.resolution, ui.uiVisible]);
// Handle animation
useEffect(() => {
if (shaderRef.current) {
const hasTime = shader.code.includes('t');
if (hasTime) {
shaderRef.current.startAnimation();
} else {
shaderRef.current.stopAnimation();
shaderRef.current.render(false);
}
}
}, [shader.code]);
// Update input data to shader
useEffect(() => {
if (shaderRef.current) {
shaderRef.current.setMousePosition(
input.mouseX,
input.mouseY,
input.mousePressed,
input.mouseVX,
input.mouseVY,
input.mouseClickTime
);
shaderRef.current.setTouchPosition(
input.touchCount,
input.touch0X,
input.touch0Y,
input.touch1X,
input.touch1Y,
input.pinchScale,
input.pinchRotation
);
shaderRef.current.setDeviceMotion(
input.accelX,
input.accelY,
input.accelZ,
input.gyroX,
input.gyroY,
input.gyroZ
);
shaderRef.current.setAudioData(
input.audioLevel,
input.bassLevel,
input.midLevel,
input.trebleLevel
);
}
}, [input]);
const setupCanvas = () => {
if (!canvasRef.current) return;
const width = window.innerWidth;
const height = ui.uiVisible
? window.innerHeight - UI_HEIGHTS.TOTAL_UI_HEIGHT
: window.innerHeight;
const scale = settings.resolution;
// Set canvas internal size with resolution scaling
canvasRef.current.width = Math.floor(width / scale);
canvasRef.current.height = Math.floor(height / scale);
console.log(
`Canvas setup: ${canvasRef.current.width}x${canvasRef.current.height} (scale: ${scale}x), UI visible: ${ui.uiVisible}`
);
};
const handleMouseMove = (e: React.MouseEvent) => {
const lastX = mouseState.current.lastX;
const lastY = mouseState.current.lastY;
const x = e.clientX / window.innerWidth;
const y = 1.0 - e.clientY / window.innerHeight; // Invert Y to match shader coordinates
const vx = x - lastX;
const vy = y - lastY;
mouseState.current.lastX = x;
mouseState.current.lastY = y;
updateMousePosition(x, y, input.mousePressed, vx, vy, input.mouseClickTime);
};
const handleMouseDown = () => {
const clickTime = Date.now();
updateMousePosition(
input.mouseX,
input.mouseY,
true,
input.mouseVX,
input.mouseVY,
clickTime
);
};
const handleMouseUp = () => {
updateMousePosition(
input.mouseX,
input.mouseY,
false,
input.mouseVX,
input.mouseVY,
input.mouseClickTime
);
};
const handleTouchStart = (e: React.TouchEvent) => {
// Only prevent default on canvas area for shader interaction
e.preventDefault();
updateTouchPositions(e.touches);
initializePinchGesture(e.touches);
};
const handleTouchMove = (e: React.TouchEvent) => {
e.preventDefault();
updateTouchPositions(e.touches);
updatePinchGesture(e.touches);
};
const handleTouchEnd = (e: React.TouchEvent) => {
e.preventDefault();
const touchCount = e.touches.length;
if (touchCount === 0) {
updateTouchPosition(0, 0, 0, 0, 0, 1, 0);
} else {
updateTouchPositions(e.touches);
}
};
const updateTouchPositions = (touches: React.TouchList | TouchList) => {
let touch0X = 0,
touch0Y = 0,
touch1X = 0,
touch1Y = 0;
if (touches.length > 0) {
touch0X = touches[0].clientX / window.innerWidth;
touch0Y = 1.0 - touches[0].clientY / window.innerHeight;
}
if (touches.length > 1) {
touch1X = touches[1].clientX / window.innerWidth;
touch1Y = 1.0 - touches[1].clientY / window.innerHeight;
}
updateTouchPosition(
touches.length,
touch0X,
touch0Y,
touch1X,
touch1Y,
input.pinchScale,
input.pinchRotation
);
};
const initializePinchGesture = (touches: React.TouchList | TouchList) => {
if (touches.length === 2) {
const dx = touches[1].clientX - touches[0].clientX;
const dy = touches[1].clientY - touches[0].clientY;
touchState.current.initialPinchDistance = Math.sqrt(dx * dx + dy * dy);
touchState.current.initialPinchAngle = Math.atan2(dy, dx);
}
};
const updatePinchGesture = (touches: React.TouchList | TouchList) => {
if (touches.length === 2 && touchState.current.initialPinchDistance > 0) {
const dx = touches[1].clientX - touches[0].clientX;
const dy = touches[1].clientY - touches[0].clientY;
const distance = Math.sqrt(dx * dx + dy * dy);
const angle = Math.atan2(dy, dx);
const pinchScale = distance / touchState.current.initialPinchDistance;
const pinchRotation = angle - touchState.current.initialPinchAngle;
updateTouchPosition(
touches.length,
input.touch0X,
input.touch0Y,
input.touch1X,
input.touch1Y,
pinchScale,
pinchRotation
);
}
};
// Set up device motion listener
useEffect(() => {
const handleDeviceMotion = (e: DeviceMotionEvent) => {
if (e.acceleration && e.rotationRate) {
updateDeviceMotion(
e.acceleration.x || 0,
e.acceleration.y || 0,
e.acceleration.z || 0,
e.rotationRate.alpha || 0,
e.rotationRate.beta || 0,
e.rotationRate.gamma || 0
);
}
};
if (window.DeviceMotionEvent) {
window.addEventListener('devicemotion', handleDeviceMotion);
return () =>
window.removeEventListener('devicemotion', handleDeviceMotion);
}
}, []);
// Set up performance warning listener
useEffect(() => {
const handlePerformanceWarning = (e: MessageEvent) => {
if (e.data === 'performance-warning') {
showPerformanceWarning();
}
};
window.addEventListener('message', handlePerformanceWarning);
return () =>
window.removeEventListener('message', handlePerformanceWarning);
}, []);
// Set up window resize listener
useEffect(() => {
const handleResize = () => setupCanvas();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [settings.resolution, ui.uiVisible]);
return (
<canvas
ref={canvasRef}
id="canvas"
style={{
position: 'fixed',
top: 0,
left: 0,
width: '100vw',
height: '100vh',
imageRendering: 'pixelated',
touchAction: 'none',
pointerEvents: 'auto',
}}
onMouseMove={handleMouseMove}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
/>
);
}

View File

@ -0,0 +1,246 @@
import React, { useState } from 'react';
import { useStore } from '@nanostores/react';
import { uiState, toggleShaderLibrary } from '../stores/ui';
import {
$library,
setSearchTerm,
getFilteredShaders,
saveShader,
deleteShader,
renameShader,
updateShaderUsage,
} from '../stores/library';
import { $shader, setShaderCode } from '../stores/shader';
import { $appSettings, updateAppSettings } from '../stores/appSettings';
export function ShaderLibrary() {
const ui = useStore(uiState);
const library = useStore($library);
const shader = useStore($shader);
const settings = useStore($appSettings);
const [shaderName, setShaderName] = useState('');
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
const filteredShaders = getFilteredShaders();
const handleSaveShader = () => {
const name = shaderName.trim();
const code = shader.code.trim();
if (!code) return;
const currentSettings = {
resolution: settings.resolution,
fps: settings.fps,
renderMode: settings.renderMode,
valueMode: settings.valueMode,
uiOpacity: settings.uiOpacity,
};
saveShader(name, code, currentSettings);
setShaderName('');
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSaveShader();
}
};
const handleLoadShader = (shaderId: string) => {
const shaderData = library.shaders.find((s) => s.id === shaderId);
if (shaderData) {
// Load the code
setShaderCode(shaderData.code);
// Apply saved settings if they exist
const newSettings: any = {};
if (shaderData.resolution) newSettings.resolution = shaderData.resolution;
if (shaderData.fps) newSettings.fps = shaderData.fps;
if (shaderData.renderMode) newSettings.renderMode = shaderData.renderMode;
if (shaderData.valueMode) newSettings.valueMode = shaderData.valueMode;
if (shaderData.uiOpacity !== undefined)
newSettings.uiOpacity = shaderData.uiOpacity;
if (Object.keys(newSettings).length > 0) {
updateAppSettings(newSettings);
}
updateShaderUsage(shaderId);
}
};
const handleDeleteShader = (shaderId: string) => {
deleteShader(shaderId);
};
const startRename = (shaderId: string) => {
const shaderData = library.shaders.find((s) => s.id === shaderId);
if (shaderData) {
setRenamingId(shaderId);
setRenameValue(shaderData.name);
}
};
const finishRename = () => {
if (renamingId && renameValue.trim()) {
renameShader(renamingId, renameValue.trim());
}
setRenamingId(null);
setRenameValue('');
};
const handleRenameKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
finishRename();
} else if (e.key === 'Escape') {
e.preventDefault();
setRenamingId(null);
setRenameValue('');
}
};
// const escapeHtml = (text: string): string => { // Unused for now
// const div = document.createElement('div');
// div.textContent = text;
// return div.innerHTML;
// };
return (
<>
<div
id="shader-library-trigger"
style={{
position: 'fixed',
top: '40px',
left: '0',
width: '20px',
height: 'calc(100vh - 40px)',
zIndex: 91,
cursor: 'pointer',
}}
onClick={toggleShaderLibrary}
></div>
<div id="shader-library" className={ui.shaderLibraryOpen ? 'open' : ''}>
<div className="library-header">
<h3>Shader Library</h3>
<div className="save-shader">
<input
type="text"
value={shaderName}
onChange={(e) => setShaderName(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Shader name..."
maxLength={30}
/>
<button onClick={handleSaveShader}>Save</button>
</div>
<div className="search-shader">
<input
type="text"
value={library.searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search shaders..."
/>
</div>
</div>
<div className="shader-list">
{filteredShaders.length === 0 ? (
<div
style={{
padding: '20px',
textAlign: 'center',
color: '#666',
fontSize: '12px',
}}
>
{library.searchTerm
? 'No shaders match your search'
: 'No saved shaders'}
</div>
) : (
filteredShaders.map((shaderData) => {
const hasSettings =
shaderData.resolution ||
shaderData.fps ||
shaderData.renderMode ||
shaderData.valueMode ||
shaderData.uiOpacity !== undefined;
const settingsIndicator = hasSettings ? ' ⚙' : '';
return (
<div key={shaderData.id} className="shader-item">
<div
className="shader-item-header"
onClick={() => handleLoadShader(shaderData.id)}
>
<span className="shader-name">
{renamingId === shaderData.id ? (
<input
type="text"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={finishRename}
onKeyDown={handleRenameKeyDown}
autoFocus
style={{
background: 'rgba(255, 255, 255, 0.2)',
border: '1px solid #666',
color: '#fff',
padding: '2px 4px',
borderRadius: '2px',
fontFamily: 'monospace',
fontSize: '12px',
width: '100%',
}}
/>
) : (
<>
{shaderData.name}
{hasSettings && (
<span
style={{ color: '#4A9EFF', fontSize: '10px' }}
title="Includes visual settings"
>
{settingsIndicator}
</span>
)}
</>
)}
</span>
<div className="shader-actions">
<button
className="shader-action rename"
onClick={(e) => {
e.stopPropagation();
startRename(shaderData.id);
}}
title="Rename"
>
edit
</button>
<button
className="shader-action delete"
onClick={(e) => {
e.stopPropagation();
handleDeleteShader(shaderData.id);
}}
title="Delete"
>
del
</button>
</div>
</div>
<div className="shader-code">{shaderData.code}</div>
</div>
);
})
)}
</div>
</div>
</>
);
}

282
src/components/TopBar.tsx Normal file
View File

@ -0,0 +1,282 @@
import { useStore } from '@nanostores/react';
import { $appSettings, updateAppSettings } from '../stores/appSettings';
import { VALUE_MODES, ValueMode } from '../utils/constants';
import {
uiState,
toggleMobileMenu,
showHelp,
toggleUI,
toggleShaderLibrary,
} from '../stores/ui';
import { $shader, setShaderCode } from '../stores/shader';
import { $input } from '../stores/input';
import { FakeShader } from '../FakeShader';
import { useAudio } from '../hooks/useAudio';
import { LucideIcon } from '../hooks/useLucideIcon';
function getValueModeLabel(mode: string): string {
const labels: Record<string, string> = {
integer: 'Integer (0-255)',
float: 'Float (0.0-1.0)',
polar: 'Polar (angle-based)',
distance: 'Distance (radial)',
wave: 'Wave (ripple)',
fractal: 'Fractal (recursive)',
cellular: 'Cellular (automata)',
noise: 'Noise (perlin-like)',
warp: 'Warp (space deformation)',
flow: 'Flow (fluid dynamics)',
};
return labels[mode] || mode;
}
export function TopBar() {
const settings = useStore($appSettings);
const ui = useStore(uiState);
const shader = useStore($shader);
const input = useStore($input);
const { setupAudio, disableAudio } = useAudio();
const handleFullscreen = () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
} else {
document.exitFullscreen();
}
};
const handleRandom = () => {
const randomCode = FakeShader.generateRandomCode();
setShaderCode(randomCode);
};
const handleShare = () => {
const shareData = {
code: shader.code,
resolution: settings.resolution,
fps: settings.fps,
renderMode: settings.renderMode,
valueMode: settings.valueMode,
uiOpacity: settings.uiOpacity,
};
const encoded = btoa(JSON.stringify(shareData));
window.location.hash = encoded;
navigator.clipboard
.writeText(window.location.href)
.then(() => {
console.log('URL copied to clipboard');
})
.catch(() => {
console.log('Copy failed');
});
};
const handleExportPNG = () => {
const canvas = document.getElementById('canvas') as HTMLCanvasElement;
if (canvas) {
const link = document.createElement('a');
link.download = `bitfielder-${Date.now()}.png`;
link.href = canvas.toDataURL('image/png');
link.click();
}
};
const handleAudioToggle = async () => {
if (input.audioEnabled) {
disableAudio();
} else {
await setupAudio();
}
};
return (
<div id="topbar" className={ui.uiVisible ? '' : 'hidden'}>
<div className="title">Bitfielder</div>
<div className="controls">
<div className="controls-desktop">
<label
style={{ color: '#ccc', fontSize: '12px', marginRight: '10px' }}
>
Resolution:
<select
value={settings.resolution}
onChange={(e) =>
updateAppSettings({ resolution: parseInt(e.target.value) })
}
style={{
background: 'rgba(255,255,255,0.1)',
border: '1px solid #555',
color: '#fff',
padding: '4px',
borderRadius: '4px',
}}
>
<option value="1">Full (1x)</option>
<option value="2">Half (2x)</option>
<option value="4">Quarter (4x)</option>
<option value="8">Eighth (8x)</option>
<option value="16">Sixteenth (16x)</option>
<option value="32">Thirty-second (32x)</option>
</select>
</label>
<label
style={{ color: '#ccc', fontSize: '12px', marginRight: '10px' }}
>
FPS:
<select
value={settings.fps}
onChange={(e) =>
updateAppSettings({ fps: parseInt(e.target.value) })
}
style={{
background: 'rgba(255,255,255,0.1)',
border: '1px solid #555',
color: '#fff',
padding: '4px',
borderRadius: '4px',
}}
>
<option value="15">15 FPS</option>
<option value="30">30 FPS</option>
<option value="60">60 FPS</option>
</select>
</label>
<label
style={{ color: '#ccc', fontSize: '12px', marginRight: '10px' }}
>
Value Mode:
<select
value={settings.valueMode}
onChange={(e) => updateAppSettings({ valueMode: e.target.value as ValueMode })}
style={{
background: 'rgba(255,255,255,0.1)',
border: '1px solid #555',
color: '#fff',
padding: '4px',
borderRadius: '4px',
}}
>
{VALUE_MODES.map((mode) => (
<option key={mode} value={mode}>
{getValueModeLabel(mode)}
</option>
))}
</select>
</label>
<label
style={{ color: '#ccc', fontSize: '12px', marginRight: '10px' }}
>
Render Mode:
<select
value={settings.renderMode}
onChange={(e) =>
updateAppSettings({ renderMode: e.target.value })
}
style={{
background: 'rgba(255,255,255,0.1)',
border: '1px solid #555',
color: '#fff',
padding: '4px',
borderRadius: '4px',
}}
>
<option value="classic">Classic</option>
<option value="grayscale">Grayscale</option>
<option value="red">Red Channel</option>
<option value="green">Green Channel</option>
<option value="blue">Blue Channel</option>
<option value="rgb">RGB Split</option>
<option value="hsv">HSV</option>
<option value="rainbow">Rainbow</option>
<option value="thermal">Thermal</option>
<option value="neon">Neon</option>
<option value="cyberpunk">Cyberpunk</option>
<option value="vaporwave">Vaporwave</option>
<option value="dithered">Dithered</option>
<option value="palette">Palette</option>
</select>
</label>
<label
style={{ color: '#ccc', fontSize: '12px', marginRight: '10px' }}
>
UI Opacity:
<input
type="range"
min="10"
max="100"
value={Math.round((settings.uiOpacity ?? 0.3) * 100)}
onChange={(e) =>
updateAppSettings({ uiOpacity: parseInt(e.target.value) / 100 })
}
style={{ width: '80px', verticalAlign: 'middle' }}
/>
<span style={{ fontSize: '11px' }}>
{Math.round((settings.uiOpacity ?? 0.3) * 100)}%
</span>
</label>
<button id="help-btn" onClick={showHelp}>
<LucideIcon name="help" />
</button>
<button id="fullscreen-btn" onClick={handleFullscreen}>
<LucideIcon name="fullscreen" />
</button>
<button id="hide-ui-btn" onClick={toggleUI}>
<LucideIcon name="hide" />
</button>
<button id="random-btn" onClick={handleRandom}>
<LucideIcon name="random" />
</button>
<button id="audio-btn" onClick={handleAudioToggle}>
<LucideIcon
name={input.audioEnabled ? 'microphone' : 'microphone-off'}
/>
</button>
<button id="share-btn" onClick={handleShare}>
<LucideIcon name="share" />
</button>
<button id="export-png-btn" onClick={handleExportPNG}>
<LucideIcon name="export" />
</button>
</div>
<div className="controls-mobile">
<button
className="icon-button"
aria-label="Shader Library"
onClick={toggleShaderLibrary}
>
<LucideIcon name="library" />
</button>
<button
className="icon-button"
aria-label="Random"
onClick={handleRandom}
>
<LucideIcon name="random" />
</button>
<button
className="icon-button"
aria-label="Hide UI"
onClick={toggleUI}
>
<LucideIcon name="hide" />
</button>
<button
className="icon-button"
aria-label="Menu"
onClick={toggleMobileMenu}
>
<LucideIcon name={ui.mobileMenuOpen ? 'close' : 'menu'} />
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,60 @@
import React, { useEffect } from 'react';
import { useStore } from '@nanostores/react';
import { uiState, hideWelcome } from '../stores/ui';
export const WelcomePopup: React.FC = () => {
const ui = useStore(uiState);
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
hideWelcome();
}
};
const handleKeyPress = () => {
hideWelcome();
};
useEffect(() => {
if (ui.welcomePopupOpen) {
window.addEventListener('keydown', handleKeyPress);
return () => {
window.removeEventListener('keydown', handleKeyPress);
};
}
}, [ui.welcomePopupOpen]);
if (!ui.welcomePopupOpen) return null;
return (
<div className="welcome-popup" onClick={handleBackdropClick}>
<div className="welcome-popup-content">
<h2 className="welcome-title">Welcome to BitFielder</h2>
<div className="welcome-content">
<p>BitFielder is an experimental lofi bitfield shader editor made by <a href="https://raphaelforment.fr">BuboBubo</a>. Use it to create visual compositions through code. I use it for fun :) </p>
<h3>Getting Started</h3>
<ul>
<li>Edit the shader code and press <i>Eval</i> or <i>Ctrl+Enter</i></li>
<li>Use special variables to create reactive effects</li>
<li>Explore/store shaders in the library (left pane)</li>
<li>Export your creations as images or sharable links</li>
</ul>
<h3>Key Features</h3>
<ul>
<li><strong>Real-time editing:</strong> See your changes instantly</li>
<li><strong>Motion and touch:</strong> Mouse, touchscreen support</li>
<li><strong>Audio reactive:</strong> Synchronize with a sound signal</li>
<li><strong>Export capabilities:</strong> Save and share your work</li>
</ul>
<p className="help-hint">Press <kbd>?</kbd> anytime to view keyboard shortcuts and detailed help.</p>
<p className="dismiss-hint">Press any key to dismiss this message</p>
</div>
</div>
</div>
);
};

118
src/hooks/useAudio.ts Normal file
View File

@ -0,0 +1,118 @@
import { useCallback, useRef } from 'react';
import { updateAudioData, setAudioEnabled } from '../stores/input';
export function useAudio() {
const audioContextRef = useRef<AudioContext | null>(null);
const analyserRef = useRef<AnalyserNode | null>(null);
const microphoneRef = useRef<MediaStreamAudioSourceNode | null>(null);
const animationFrameRef = useRef<number | null>(null);
const setupAudio = useCallback(async (): Promise<boolean> => {
try {
if (!window.AudioContext && !(window as any).webkitAudioContext) {
console.warn('Web Audio API not supported');
return false;
}
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
audioContextRef.current = new (window.AudioContext ||
(window as any).webkitAudioContext)();
analyserRef.current = audioContextRef.current.createAnalyser();
analyserRef.current.fftSize = 256;
analyserRef.current.smoothingTimeConstant = 0.8;
microphoneRef.current =
audioContextRef.current.createMediaStreamSource(stream);
microphoneRef.current.connect(analyserRef.current);
setAudioEnabled(true);
startAudioAnalysis();
return true;
} catch (error) {
console.warn('Failed to setup audio:', error);
setAudioEnabled(false);
return false;
}
}, []);
const disableAudio = useCallback(() => {
setAudioEnabled(false);
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
if (microphoneRef.current) {
microphoneRef.current.disconnect();
microphoneRef.current = null;
}
if (audioContextRef.current) {
audioContextRef.current.close();
audioContextRef.current = null;
}
analyserRef.current = null;
// Reset audio levels
updateAudioData(0, 0, 0, 0);
}, []);
const startAudioAnalysis = useCallback(() => {
if (!analyserRef.current) return;
const bufferLength = analyserRef.current.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
const analyze = () => {
if (!analyserRef.current) return;
analyserRef.current.getByteFrequencyData(dataArray);
// Calculate overall audio level (RMS)
let sum = 0;
for (let i = 0; i < bufferLength; i++) {
sum += dataArray[i] * dataArray[i];
}
const audioLevel = Math.sqrt(sum / bufferLength) / 255;
// Calculate frequency bands
const lowEnd = Math.floor(bufferLength * 0.08);
const midEnd = Math.floor(bufferLength * 0.67);
// Bass (low frequencies)
let bassSum = 0;
for (let i = 0; i < lowEnd; i++) {
bassSum += dataArray[i];
}
const bassLevel = bassSum / lowEnd / 255;
// Mid frequencies
let midSum = 0;
for (let i = lowEnd; i < midEnd; i++) {
midSum += dataArray[i];
}
const midLevel = midSum / (midEnd - lowEnd) / 255;
// Treble (high frequencies)
let trebleSum = 0;
for (let i = midEnd; i < bufferLength; i++) {
trebleSum += dataArray[i];
}
const trebleLevel = trebleSum / (bufferLength - midEnd) / 255;
updateAudioData(audioLevel, bassLevel, midLevel, trebleLevel);
animationFrameRef.current = requestAnimationFrame(analyze);
};
analyze();
}, []);
return {
setupAudio,
disableAudio,
};
}

View File

@ -0,0 +1,77 @@
import { useEffect } from 'react';
import { uiState } from '../stores/ui';
import { $shader, setShaderCode } from '../stores/shader';
import { $appSettings, cycleValueMode } from '../stores/appSettings';
import { FakeShader } from '../FakeShader';
export function useKeyboardShortcuts() {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const activeElement = document.activeElement;
const isInputFocused = activeElement?.matches('input, textarea, select');
if (e.key === 'F11') {
e.preventDefault();
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
} else {
document.exitFullscreen();
}
} else if (!isInputFocused) {
if (e.key === 'h' || e.key === 'H') {
const ui = uiState.get();
uiState.set({ ...ui, uiVisible: !ui.uiVisible });
} else if (e.key === 'r' || e.key === 'R') {
const randomCode = FakeShader.generateRandomCode();
setShaderCode(randomCode);
} else if (e.key === 's' || e.key === 'S') {
shareURL();
} else if (e.key === 'm' || e.key === 'M') {
cycleValueMode();
} else if (e.key === '?') {
const ui = uiState.get();
uiState.set({ ...ui, helpPopupOpen: true });
}
}
if (e.key === 'Escape') {
const ui = uiState.get();
uiState.set({
...ui,
helpPopupOpen: false,
uiVisible: true,
mobileMenuOpen: false,
});
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, []);
}
function shareURL() {
const shader = $shader.get();
const settings = $appSettings.get();
const shareData = {
code: shader.code,
resolution: settings.resolution,
fps: settings.fps,
renderMode: settings.renderMode,
valueMode: settings.valueMode,
uiOpacity: settings.uiOpacity,
};
const encoded = btoa(JSON.stringify(shareData));
window.location.hash = encoded;
navigator.clipboard
.writeText(window.location.href)
.then(() => {
console.log('URL copied to clipboard');
})
.catch(() => {
console.log('Copy failed');
});
}

View File

@ -0,0 +1,59 @@
import React, { useEffect, useRef } from 'react';
import { createIcons, icons } from 'lucide';
export function useLucideIcon(
iconName: string,
size: number = 16
): React.RefObject<HTMLElement | null> {
const iconRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (iconRef.current) {
const iconMap: Record<string, string> = {
menu: 'menu',
close: 'x',
help: 'help-circle',
fullscreen: 'maximize-2',
show: 'eye',
hide: 'eye-off',
random: 'dice-3',
share: 'share-2',
export: 'download',
play: 'play',
settings: 'settings',
resolution: 'monitor',
fps: 'zap',
palette: 'palette',
library: 'book-open',
microphone: 'mic',
'microphone-off': 'mic-off',
};
const lucideIconName = iconMap[iconName] || iconName;
iconRef.current.setAttribute('data-lucide', lucideIconName);
iconRef.current.setAttribute('width', size.toString());
iconRef.current.setAttribute('height', size.toString());
iconRef.current.setAttribute('stroke-width', '2');
// Initialize the specific icon
createIcons({ icons });
}
}, [iconName, size]);
return iconRef;
}
// Component for rendering Lucide icons in React
export function LucideIcon({
name,
size = 16,
className = '',
}: {
name: string;
size?: number;
className?: string;
}) {
const iconRef = useLucideIcon(name, size);
return <i ref={iconRef} className={className} />;
}

View File

@ -1,42 +1,46 @@
import { createIcons, icons } from 'lucide';
export function createIcon(name: string, size: number = 16): string {
const iconMap: Record<string, string> = {
'menu': 'menu',
'close': 'x',
'help': 'help-circle',
'fullscreen': 'maximize-2',
'show': 'eye',
'hide': 'eye-off',
'random': 'dice-3',
'share': 'share-2',
'export': 'download',
'play': 'play',
'settings': 'settings',
'resolution': 'monitor',
'fps': 'zap',
'palette': 'palette',
'library': 'book-open'
};
const iconName = iconMap[name];
if (!iconName) return '';
return `<i data-lucide="${iconName}" width="${size}" height="${size}" stroke-width="2"></i>`;
const iconMap: Record<string, string> = {
menu: 'menu',
close: 'x',
help: 'help-circle',
fullscreen: 'maximize-2',
show: 'eye',
hide: 'eye-off',
random: 'dice-3',
share: 'share-2',
export: 'download',
play: 'play',
settings: 'settings',
resolution: 'monitor',
fps: 'zap',
palette: 'palette',
library: 'book-open',
};
const iconName = iconMap[name];
if (!iconName) return '';
return `<i data-lucide="${iconName}" width="${size}" height="${size}" stroke-width="2"></i>`;
}
export function addIconToButton(button: HTMLElement, iconName: string, keepText: boolean = false): void {
const originalText = button.textContent || '';
const iconHtml = createIcon(iconName);
if (keepText) {
button.innerHTML = iconHtml + ' ' + originalText;
} else {
button.innerHTML = iconHtml;
button.setAttribute('aria-label', originalText);
}
export function addIconToButton(
button: HTMLElement,
iconName: string,
keepText: boolean = false
): void {
const originalText = button.textContent || '';
const iconHtml = createIcon(iconName);
if (keepText) {
button.innerHTML = iconHtml + ' ' + originalText;
} else {
button.innerHTML = iconHtml;
button.setAttribute('aria-label', originalText);
}
}
export function initializeLucideIcons(): void {
createIcons({ icons });
}
createIcons({ icons });
}

File diff suppressed because it is too large Load Diff

66
src/main.tsx Normal file
View File

@ -0,0 +1,66 @@
import { createRoot } from 'react-dom/client';
import { App } from './components/App';
import { Storage } from './Storage';
import { $appSettings } from './stores/appSettings';
import { setShaderCode } from './stores/shader';
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
// Load initial state from storage
const savedSettings = Storage.getSettings();
$appSettings.set(savedSettings);
// Load URL hash if present
function loadFromURL() {
if (window.location.hash) {
try {
const decoded = atob(window.location.hash.substring(1));
try {
const shareData = JSON.parse(decoded);
setShaderCode(shareData.code);
$appSettings.set({
resolution: shareData.resolution || savedSettings.resolution,
fps: shareData.fps || savedSettings.fps,
renderMode: shareData.renderMode || savedSettings.renderMode,
valueMode: shareData.valueMode || savedSettings.valueMode,
uiOpacity:
shareData.uiOpacity !== undefined
? shareData.uiOpacity
: savedSettings.uiOpacity,
});
} catch (jsonError) {
// Fall back to old format (just code as string)
setShaderCode(decoded);
}
} catch (e) {
console.error('Failed to decode URL hash:', e);
}
} else {
// Load last shader code if no URL hash
setShaderCode(savedSettings.lastShaderCode ?? 'x^y');
}
}
// Main App component that includes keyboard shortcuts
function AppWithShortcuts() {
useKeyboardShortcuts();
return <App />;
}
// Set up hash change listener
window.addEventListener('hashchange', loadFromURL);
// Initialize the app
loadFromURL();
// Mount React app
const container = document.getElementById('app');
if (!container) {
// Create app container if it doesn't exist
const appDiv = document.createElement('div');
appDiv.id = 'app';
document.body.appendChild(appDiv);
}
const root = createRoot(container || document.getElementById('app')!);
root.render(<AppWithShortcuts />);

39
src/stores/appSettings.ts Normal file
View File

@ -0,0 +1,39 @@
import { atom } from 'nanostores';
import { DEFAULTS, VALUE_MODES, ValueMode } from '../utils/constants';
export interface AppSettings {
resolution: number;
fps: number;
renderMode: string;
valueMode?: ValueMode;
uiOpacity?: number;
lastShaderCode?: string;
}
export const defaultSettings: AppSettings = {
resolution: DEFAULTS.RESOLUTION,
fps: DEFAULTS.FPS,
renderMode: DEFAULTS.RENDER_MODE,
valueMode: DEFAULTS.VALUE_MODE,
uiOpacity: DEFAULTS.UI_OPACITY,
lastShaderCode: DEFAULTS.SHADER_CODE,
};
export const $appSettings = atom<AppSettings>(defaultSettings);
export function updateAppSettings(settings: Partial<AppSettings>) {
$appSettings.set({ ...$appSettings.get(), ...settings });
}
export function cycleValueMode() {
const currentSettings = $appSettings.get();
const currentMode = currentSettings.valueMode || DEFAULTS.VALUE_MODE;
const currentIndex = VALUE_MODES.indexOf(currentMode);
const nextIndex = (currentIndex + 1) % VALUE_MODES.length;
const nextMode = VALUE_MODES[nextIndex];
updateAppSettings({ valueMode: nextMode });
// Return the new mode for UI feedback
return nextMode;
}

135
src/stores/input.ts Normal file
View File

@ -0,0 +1,135 @@
import { atom } from 'nanostores';
export interface InputState {
mouseX: number;
mouseY: number;
mousePressed: boolean;
mouseVX: number;
mouseVY: number;
mouseClickTime: number;
touchCount: number;
touch0X: number;
touch0Y: number;
touch1X: number;
touch1Y: number;
pinchScale: number;
pinchRotation: number;
accelX: number;
accelY: number;
accelZ: number;
gyroX: number;
gyroY: number;
gyroZ: number;
audioLevel: number;
bassLevel: number;
midLevel: number;
trebleLevel: number;
audioEnabled: boolean;
}
export const defaultInputState: InputState = {
mouseX: 0,
mouseY: 0,
mousePressed: false,
mouseVX: 0,
mouseVY: 0,
mouseClickTime: 0,
touchCount: 0,
touch0X: 0,
touch0Y: 0,
touch1X: 0,
touch1Y: 0,
pinchScale: 1,
pinchRotation: 0,
accelX: 0,
accelY: 0,
accelZ: 0,
gyroX: 0,
gyroY: 0,
gyroZ: 0,
audioLevel: 0,
bassLevel: 0,
midLevel: 0,
trebleLevel: 0,
audioEnabled: false,
};
export const $input = atom<InputState>(defaultInputState);
export function updateMousePosition(
x: number,
y: number,
pressed: boolean,
vx: number,
vy: number,
clickTime: number
) {
$input.set({
...$input.get(),
mouseX: x,
mouseY: y,
mousePressed: pressed,
mouseVX: vx,
mouseVY: vy,
mouseClickTime: clickTime,
});
}
export function updateTouchPosition(
count: number,
x0: number,
y0: number,
x1: number,
y1: number,
scale: number,
rotation: number
) {
$input.set({
...$input.get(),
touchCount: count,
touch0X: x0,
touch0Y: y0,
touch1X: x1,
touch1Y: y1,
pinchScale: scale,
pinchRotation: rotation,
});
}
export function updateDeviceMotion(
ax: number,
ay: number,
az: number,
gx: number,
gy: number,
gz: number
) {
$input.set({
...$input.get(),
accelX: ax,
accelY: ay,
accelZ: az,
gyroX: gx,
gyroY: gy,
gyroZ: gz,
});
}
export function updateAudioData(
level: number,
bass: number,
mid: number,
treble: number
) {
$input.set({
...$input.get(),
audioLevel: level,
bassLevel: bass,
midLevel: mid,
trebleLevel: treble,
});
}
export function setAudioEnabled(enabled: boolean) {
$input.set({ ...$input.get(), audioEnabled: enabled });
}

59
src/stores/library.ts Normal file
View File

@ -0,0 +1,59 @@
import { atom } from 'nanostores';
import { Storage, SavedShader } from '../Storage';
export interface LibraryState {
shaders: SavedShader[];
searchTerm: string;
}
export const defaultLibraryState: LibraryState = {
shaders: [],
searchTerm: '',
};
export const $library = atom<LibraryState>(defaultLibraryState);
export function loadShaders() {
const shaders = Storage.getShaders();
$library.set({ ...$library.get(), shaders });
}
export function saveShader(name: string, code: string, settings?: any) {
const shader = Storage.saveShader(name, code, settings);
loadShaders(); // Reload to get updated list
return shader;
}
export function deleteShader(id: string) {
Storage.deleteShader(id);
loadShaders();
}
export function renameShader(id: string, newName: string) {
Storage.renameShader(id, newName);
loadShaders();
}
export function updateShaderUsage(id: string) {
Storage.updateShaderUsage(id);
loadShaders();
}
export function setSearchTerm(term: string) {
$library.set({ ...$library.get(), searchTerm: term });
}
export function getFilteredShaders(): SavedShader[] {
const { shaders, searchTerm } = $library.get();
if (!searchTerm.trim()) {
return shaders;
}
const term = searchTerm.toLowerCase();
return shaders.filter(
(shader) =>
shader.name.toLowerCase().includes(term) ||
shader.code.toLowerCase().includes(term)
);
}

33
src/stores/shader.ts Normal file
View File

@ -0,0 +1,33 @@
import { atom } from 'nanostores';
export interface ShaderState {
code: string;
isCompiled: boolean;
isAnimating: boolean;
error: string | null;
}
export const defaultShaderState: ShaderState = {
code: 'x^y',
isCompiled: false,
isAnimating: false,
error: null,
};
export const $shader = atom<ShaderState>(defaultShaderState);
export function setShaderCode(code: string) {
$shader.set({ ...$shader.get(), code, isCompiled: false, error: null });
}
export function setShaderCompiled(isCompiled: boolean, error?: string) {
$shader.set({
...$shader.get(),
isCompiled,
error: error || null,
});
}
export function setShaderAnimating(isAnimating: boolean) {
$shader.set({ ...$shader.get(), isAnimating });
}

64
src/stores/ui.ts Normal file
View File

@ -0,0 +1,64 @@
import { atom } from 'nanostores';
export interface UIState {
mobileMenuOpen: boolean;
helpPopupOpen: boolean;
shaderLibraryOpen: boolean;
uiVisible: boolean;
performanceWarningVisible: boolean;
welcomePopupOpen: boolean;
}
export const defaultUIState: UIState = {
mobileMenuOpen: false,
helpPopupOpen: false,
shaderLibraryOpen: false,
uiVisible: true,
performanceWarningVisible: false,
welcomePopupOpen: true,
};
export const uiState = atom<UIState>(defaultUIState);
export function toggleMobileMenu() {
uiState.set({ ...uiState.get(), mobileMenuOpen: !uiState.get().mobileMenuOpen });
}
export function closeMobileMenu() {
uiState.set({ ...uiState.get(), mobileMenuOpen: false });
}
export function showHelp() {
uiState.set({ ...uiState.get(), helpPopupOpen: true });
}
export function hideHelp() {
uiState.set({ ...uiState.get(), helpPopupOpen: false });
}
export function toggleShaderLibrary() {
uiState.set({ ...uiState.get(), shaderLibraryOpen: !uiState.get().shaderLibraryOpen });
}
export function toggleUI() {
uiState.set({ ...uiState.get(), uiVisible: !uiState.get().uiVisible });
}
export function showUI() {
uiState.set({ ...uiState.get(), uiVisible: true });
}
export function showPerformanceWarning() {
uiState.set({ ...uiState.get(), performanceWarningVisible: true });
setTimeout(() => {
uiState.set({ ...uiState.get(), performanceWarningVisible: false });
}, 3000);
}
export function showWelcome() {
uiState.set({ ...uiState.get(), welcomePopupOpen: true });
}
export function hideWelcome() {
uiState.set({ ...uiState.get(), welcomePopupOpen: false });
}

854
src/styles/main.css Normal file
View File

@ -0,0 +1,854 @@
:root {
--ui-opacity: 0.3;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
}
*::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
body {
background: #000;
color: #fff;
font-family: 'IBM Plex Mono', monospace;
overflow: hidden;
touch-action: manipulation; /* Allow pan and zoom but disable double-tap zoom */
}
a {
color: #ff9500;
text-decoration: none;
}
a:hover {
color: #ffb143;
text-decoration: underline;
}
a:visited {
color: #ff9500;
}
#canvas {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
image-rendering: pixelated;
image-rendering: -moz-crisp-edges;
image-rendering: crisp-edges;
touch-action: none; /* Disable all touch gestures on canvas for shader interaction */
pointer-events: auto; /* Allow canvas interactions */
}
#topbar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 40px;
background: rgba(0, 0, 0, var(--ui-opacity));
border-bottom: 1px solid #333;
display: flex;
align-items: center;
padding: 0 20px;
z-index: 100;
pointer-events: auto; /* Ensure topbar can be clicked */
touch-action: manipulation; /* Allow normal touch interactions */
}
#topbar .title {
color: #fff;
font-size: 14px;
font-weight: bold;
margin-right: 20px;
}
#topbar .controls {
display: flex;
gap: 10px;
margin-left: auto;
align-items: center;
}
#topbar .controls-desktop {
display: flex;
gap: 10px;
align-items: center;
}
#topbar .controls-mobile {
display: none;
gap: 8px;
align-items: center;
margin-left: auto;
}
#hamburger-menu {
display: none;
background: rgba(255, 255, 255, 0.1);
border: 1px solid #555;
color: #fff;
width: 36px;
height: 36px;
padding: 0;
border-radius: 4px;
cursor: pointer;
align-items: center;
justify-content: center;
}
#hamburger-menu:hover {
background: rgba(255, 255, 255, 0.2);
}
#hamburger-menu svg {
width: 18px;
height: 18px;
}
#mobile-menu {
position: fixed;
top: 40px;
right: -320px;
width: 320px;
max-width: 80vw;
height: calc(100vh - 40px);
background: rgba(0, 0, 0, var(--ui-opacity));
backdrop-filter: blur(3px);
border-left: 1px solid rgba(255, 255, 255, 0.1);
z-index: 150;
transition: right 0.3s ease;
overflow-y: auto;
padding: 20px;
pointer-events: auto; /* Ensure mobile menu can be clicked */
touch-action: manipulation; /* Allow normal touch interactions */
}
#mobile-menu.open {
right: 0;
}
#mobile-menu h3 {
color: #fff;
font-size: 16px;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.mobile-menu-section {
margin-bottom: 20px;
}
.mobile-menu-item {
margin-bottom: 15px;
}
.mobile-menu-item label {
display: block;
color: #ccc;
font-size: 12px;
margin-bottom: 5px;
}
.mobile-menu-item select,
.mobile-menu-item input[type='range'] {
width: 100%;
background: rgba(255, 255, 255, 0.1);
border: 1px solid #555;
color: #fff;
padding: 8px;
border-radius: 4px;
font-size: 14px;
}
.mobile-menu-buttons {
display: flex;
flex-direction: column;
gap: 10px;
}
.mobile-menu-buttons button {
background: rgba(255, 255, 255, 0.1);
border: 1px solid #555;
color: #fff;
padding: 12px;
border-radius: 4px;
cursor: pointer;
font-family: 'IBM Plex Mono', monospace;
font-size: 14px;
text-align: left;
display: flex;
align-items: center;
gap: 10px;
}
.mobile-menu-buttons button:hover {
background: rgba(255, 255, 255, 0.2);
}
#mobile-menu-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 149;
pointer-events: none; /* Don't block clicks when hidden */
}
#mobile-menu-overlay.open {
display: block;
pointer-events: auto; /* Only block clicks when visible */
}
#topbar button {
background: rgba(255, 255, 255, 0.1);
border: 1px solid #555;
color: #fff;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
}
#topbar button:hover {
background: rgba(255, 255, 255, 0.2);
}
.icon-button {
width: 36px;
height: 36px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
}
.icon-button svg {
width: 18px;
height: 18px;
}
/* Lucide icon styles */
[data-lucide] {
display: inline-block;
vertical-align: middle;
}
button svg {
pointer-events: none;
}
/* Ensure all button contents don't intercept clicks */
button *,
button svg,
button [data-lucide] {
pointer-events: none !important;
}
#editor-panel {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 140px;
display: flex;
align-items: stretch;
gap: 10px;
padding: 10px;
z-index: 100;
transition: all 0.3s ease;
pointer-events: auto; /* Ensure editor panel can be clicked */
touch-action: manipulation; /* Allow normal touch interactions */
}
#editor-panel.minimal {
height: 50px;
bottom: 20px;
left: 20px;
right: 20px;
padding: 5px;
}
#editor {
flex: 1;
background: rgba(0, 0, 0, var(--ui-opacity));
backdrop-filter: blur(2px);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
color: #fff;
font-family: 'IBM Plex Mono', monospace;
font-size: 16px;
padding: 15px;
resize: none;
outline: none;
transition: all 0.3s ease;
touch-action: manipulation; /* Allow normal touch interactions for text editing */
}
#eval-btn {
background: rgba(0, 0, 0, var(--ui-opacity));
backdrop-filter: blur(2px);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #fff;
padding: 20px 30px;
font-family: 'IBM Plex Mono', monospace;
font-size: 16px;
font-weight: bold;
cursor: pointer;
border-radius: 4px;
transition: all 0.2s ease;
align-self: stretch;
}
#eval-btn:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.4);
}
#eval-btn:active {
transform: scale(0.95);
}
#editor.minimal {
padding: 12px 15px;
font-size: 14px;
}
#eval-btn.minimal {
padding: 10px 20px;
font-size: 14px;
}
#help-popup {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.95);
border: 1px solid #555;
border-radius: 8px;
padding: 30px;
z-index: 1000;
max-width: 90vw;
width: 800px;
max-height: 80vh;
overflow-y: auto;
display: none;
pointer-events: auto; /* Ensure help popup can be clicked */
touch-action: manipulation; /* Allow normal touch interactions */
}
#help-popup h3 {
margin-bottom: 20px;
color: #fff;
font-size: 18px;
text-align: center;
}
.help-content {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 30px;
margin-top: 20px;
}
#help-popup .help-section {
margin-bottom: 0;
}
#help-popup .help-section h4 {
color: #ccc;
margin-bottom: 10px;
font-size: 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
padding-bottom: 5px;
}
#help-popup .help-section p {
color: #999;
font-size: 12px;
line-height: 1.5;
margin-bottom: 8px;
}
#help-popup .close-btn {
position: absolute;
top: 10px;
right: 15px;
background: none;
border: none;
color: #999;
font-size: 20px;
cursor: pointer;
}
.hidden {
display: none !important;
}
/* Welcome Popup */
.welcome-popup {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.9);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.welcome-popup-content {
position: relative;
background: rgba(20, 20, 20, 0.95);
border: 1px solid #555;
border-radius: 8px;
padding: 40px;
max-width: 600px;
width: 100%;
max-height: 80vh;
overflow-y: auto;
}
.welcome-title {
margin-bottom: 30px;
color: #fff;
font-size: 24px;
text-align: center;
}
.welcome-content {
color: #ccc;
}
.welcome-content p {
margin-bottom: 20px;
line-height: 1.6;
}
.welcome-content h3 {
color: #fff;
margin-top: 25px;
margin-bottom: 15px;
font-size: 16px;
}
.welcome-content ul {
list-style: none;
padding-left: 0;
}
.welcome-content li {
margin-bottom: 10px;
padding-left: 20px;
position: relative;
}
.welcome-content li:before {
content: "▸";
position: absolute;
left: 0;
color: #999;
}
.welcome-content strong {
color: #fff;
}
.help-hint {
margin-top: 30px;
padding: 10px 15px;
background: rgba(52, 152, 219, 0.2);
border-radius: 4px;
border-left: 3px solid #3498db;
}
.help-hint kbd {
background: rgba(255, 255, 255, 0.1);
padding: 2px 6px;
border-radius: 3px;
font-family: 'IBM Plex Mono', monospace;
font-size: 0.9em;
}
.dismiss-hint {
margin-top: 20px;
text-align: center;
color: #999;
font-size: 0.9em;
}
#show-ui-btn {
position: fixed;
top: 10px;
right: 10px;
background: rgba(0, 0, 0, var(--ui-opacity));
border: 1px solid #555;
color: #fff;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
z-index: 1000;
display: none;
}
#show-ui-btn:hover {
background: rgba(0, 0, 0, 0.9);
}
#shader-library {
position: fixed;
top: 40px;
left: -300px;
width: 300px;
height: calc(100vh - 40px);
background: rgba(0, 0, 0, calc(var(--ui-opacity) + 0.1));
border-right: 1px solid rgba(255, 255, 255, 0.1);
z-index: 90;
transition: left 0.3s ease;
backdrop-filter: blur(3px);
overflow-y: auto;
pointer-events: auto; /* Ensure shader library can be clicked */
touch-action: manipulation; /* Allow normal touch interactions */
}
#shader-library-trigger {
position: fixed;
top: 40px;
left: 0;
width: 20px;
height: calc(100vh - 40px);
z-index: 91;
cursor: pointer;
}
#shader-library-trigger:hover + #shader-library,
#shader-library:hover {
left: 0;
}
#shader-library.open {
left: 0;
}
.library-header {
padding: 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.library-header h3 {
margin: 0 0 15px 0;
color: #fff;
font-size: 16px;
}
.save-shader {
display: flex;
gap: 8px;
margin-bottom: 15px;
}
.search-shader {
margin-bottom: 10px;
}
.search-shader input {
width: 100%;
background: rgba(255, 255, 255, 0.1);
border: 1px solid #555;
color: #fff;
padding: 6px 8px;
border-radius: 4px;
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
}
.search-shader input::placeholder {
color: #999;
}
.save-shader input {
flex: 1;
background: rgba(255, 255, 255, 0.1);
border: 1px solid #555;
color: #fff;
padding: 6px 8px;
border-radius: 4px;
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
}
.save-shader button {
background: rgba(255, 255, 255, 0.1);
border: 1px solid #555;
color: #fff;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
}
.save-shader button:hover {
background: rgba(255, 255, 255, 0.2);
}
.shader-list {
padding: 0 20px 20px 20px;
}
.shader-item {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
margin-bottom: 8px;
overflow: hidden;
}
.shader-item-header {
padding: 10px;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
}
.shader-item-header:hover {
background: rgba(255, 255, 255, 0.1);
}
.shader-name {
color: #fff;
font-size: 12px;
font-weight: bold;
}
.shader-actions {
display: flex;
gap: 4px;
}
.shader-action {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #ccc;
cursor: pointer;
font-size: 10px;
padding: 4px 6px;
border-radius: 3px;
transition: all 0.2s ease;
font-family: 'IBM Plex Mono', monospace;
}
.shader-action:hover {
background: rgba(255, 255, 255, 0.2);
color: #fff;
transform: scale(1.05);
}
.shader-action.rename {
background: rgba(52, 152, 219, 0.3);
border-color: rgba(52, 152, 219, 0.5);
}
.shader-action.rename:hover {
background: rgba(52, 152, 219, 0.5);
}
.shader-action.delete {
background: rgba(231, 76, 60, 0.3);
border-color: rgba(231, 76, 60, 0.5);
}
.shader-action.delete:hover {
background: rgba(231, 76, 60, 0.5);
}
.shader-code {
padding: 8px 10px;
background: rgba(0, 0, 0, var(--ui-opacity));
color: #ccc;
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
word-break: break-all;
}
#performance-warning {
position: fixed;
top: 50px;
right: 20px;
background: rgba(255, 0, 0, 0.8);
color: #fff;
padding: 10px 15px;
border-radius: 4px;
font-size: 12px;
z-index: 1001;
display: none;
}
/* Responsive Design */
@media (max-width: 768px) {
#topbar .controls {
margin-left: auto;
}
#topbar .controls-desktop {
display: none;
}
#topbar .controls-mobile {
display: flex;
}
#hamburger-menu {
display: flex;
}
#topbar {
height: 40px;
padding: 0 10px;
}
#topbar .title {
margin-right: auto;
}
#topbar .controls {
flex-wrap: wrap;
gap: 5px;
margin-left: 0;
}
#topbar button {
padding: 4px 8px;
font-size: 11px;
}
#topbar label {
font-size: 11px !important;
margin-right: 5px !important;
}
#topbar select {
padding: 2px !important;
font-size: 11px !important;
}
#help-popup {
width: 95vw;
max-width: 95vw;
max-height: 90vh;
padding: 20px;
}
.help-content {
grid-template-columns: 1fr;
gap: 20px;
}
#editor-panel {
height: 120px;
}
#editor {
font-size: 14px;
padding: 10px;
}
#shader-library {
width: 100%;
left: -100%;
top: 40px;
height: calc(100vh - 40px);
}
#shader-library-trigger {
display: none;
}
}
@media (max-width: 480px) {
#topbar {
padding: 5px;
}
#topbar .title {
font-size: 12px;
}
#topbar button {
padding: 3px 6px;
font-size: 10px;
}
#topbar label {
font-size: 10px !important;
}
#topbar select {
font-size: 10px !important;
}
#help-popup {
padding: 15px;
}
#help-popup h3 {
font-size: 16px;
}
#help-popup .help-section h4 {
font-size: 13px;
}
#help-popup .help-section p {
font-size: 11px;
}
.welcome-popup-content {
padding: 20px;
}
.welcome-title {
font-size: 20px;
}
.welcome-content h3 {
font-size: 14px;
}
#editor-panel {
height: 100px;
}
#editor {
font-size: 12px;
padding: 8px;
}
}
@media (min-width: 1200px) {
.help-content {
grid-template-columns: repeat(3, 1fr);
}
}

72
src/utils/LRUCache.ts Normal file
View File

@ -0,0 +1,72 @@
export class LRUCache<K, V> {
private maxSize: number;
private cache: Map<K, V>;
private accessOrder: K[];
constructor(maxSize: number) {
this.maxSize = maxSize;
this.cache = new Map();
this.accessOrder = [];
}
get(key: K): V | undefined {
const value = this.cache.get(key);
if (value !== undefined) {
this.markAsUsed(key);
}
return value;
}
set(key: K, value: V): void {
if (this.cache.has(key)) {
this.cache.set(key, value);
this.markAsUsed(key);
} else {
if (this.cache.size >= this.maxSize) {
this.evictLeastUsed();
}
this.cache.set(key, value);
this.accessOrder.push(key);
}
}
has(key: K): boolean {
return this.cache.has(key);
}
delete(key: K): boolean {
if (this.cache.delete(key)) {
this.removeFromAccessOrder(key);
return true;
}
return false;
}
clear(): void {
this.cache.clear();
this.accessOrder = [];
}
get size(): number {
return this.cache.size;
}
private markAsUsed(key: K): void {
this.removeFromAccessOrder(key);
this.accessOrder.push(key);
}
private removeFromAccessOrder(key: K): void {
const index = this.accessOrder.indexOf(key);
if (index > -1) {
this.accessOrder.splice(index, 1);
}
}
private evictLeastUsed(): void {
if (this.accessOrder.length > 0) {
const leastUsed = this.accessOrder.shift()!;
this.cache.delete(leastUsed);
}
}
}

205
src/utils/colorModes.ts Normal file
View File

@ -0,0 +1,205 @@
export function hsvToRgb(h: number, s: number, v: number): [number, number, number] {
const c = v * s;
const x = c * (1 - Math.abs(((h * 6) % 2) - 1));
const m = v - c;
let r = 0,
g = 0,
b = 0;
if (h < 1 / 6) {
r = c;
g = x;
b = 0;
} else if (h < 2 / 6) {
r = x;
g = c;
b = 0;
} else if (h < 3 / 6) {
r = 0;
g = c;
b = x;
} else if (h < 4 / 6) {
r = 0;
g = x;
b = c;
} else if (h < 5 / 6) {
r = x;
g = 0;
b = c;
} else {
r = c;
g = 0;
b = x;
}
return [
Math.round((r + m) * 255),
Math.round((g + m) * 255),
Math.round((b + m) * 255),
];
}
export function rainbowColor(value: number): [number, number, number] {
const phase = (value / 255.0) * 6;
const segment = Math.floor(phase);
const remainder = phase - segment;
const t = remainder;
const q = 1 - t;
switch (segment % 6) {
case 0:
return [255, Math.round(t * 255), 0];
case 1:
return [Math.round(q * 255), 255, 0];
case 2:
return [0, 255, Math.round(t * 255)];
case 3:
return [0, Math.round(q * 255), 255];
case 4:
return [Math.round(t * 255), 0, 255];
case 5:
return [255, 0, Math.round(q * 255)];
default:
return [255, 255, 255];
}
}
export function thermalColor(value: number): [number, number, number] {
const t = value / 255.0;
if (t < 0.25) {
return [0, 0, Math.round(t * 4 * 255)];
} else if (t < 0.5) {
return [0, Math.round((t - 0.25) * 4 * 255), 255];
} else if (t < 0.75) {
return [
Math.round((t - 0.5) * 4 * 255),
255,
Math.round((0.75 - t) * 4 * 255),
];
} else {
return [255, 255, Math.round((t - 0.75) * 4 * 255)];
}
}
export function neonColor(value: number): [number, number, number] {
const t = value / 255.0;
const intensity = Math.pow(Math.sin(t * Math.PI), 2);
const glow = Math.pow(intensity, 0.5);
return [
Math.round(glow * 255),
Math.round(intensity * 255),
Math.round(Math.pow(intensity, 2) * 255),
];
}
export function cyberpunkColor(value: number): [number, number, number] {
const t = value / 255.0;
const phase = (t * 3) % 1;
if (phase < 0.33) {
return [
Math.round(255 * (1 - phase * 3)),
0,
Math.round(255 * phase * 3),
];
} else if (phase < 0.67) {
const p = (phase - 0.33) * 3;
return [0, Math.round(255 * p), Math.round(255 * (1 - p))];
} else {
const p = (phase - 0.67) * 3;
return [Math.round(255 * p), Math.round(255 * (1 - p)), 255];
}
}
export function vaporwaveColor(value: number): [number, number, number] {
const t = value / 255.0;
const pink = Math.sin(t * Math.PI);
const purple = Math.sin(t * Math.PI * 0.7 + Math.PI / 3);
const blue = Math.sin(t * Math.PI * 0.5 + Math.PI / 2);
return [
Math.round(255 * (0.8 + 0.2 * pink)),
Math.round(255 * (0.3 + 0.7 * purple)),
Math.round(255 * (0.6 + 0.4 * blue)),
];
}
export function ditheredColor(value: number): [number, number, number] {
const levels = 4;
const step = 255 / (levels - 1);
const quantized = Math.round(value / step) * step;
const error = value - quantized;
const dither = (Math.random() - 0.5) * 32;
const final = Math.max(0, Math.min(255, quantized + error + dither));
return [final, final, final];
}
export function paletteColor(value: number): [number, number, number] {
const palette = [
[0, 0, 0],
[87, 29, 149],
[191, 82, 177],
[249, 162, 162],
[255, 241, 165],
[134, 227, 206],
[29, 161, 242],
[255, 255, 255],
];
const index = Math.floor((value / 255.0) * (palette.length - 1));
return palette[index] as [number, number, number];
}
export function calculateColorDirect(
absValue: number,
renderMode: string
): [number, number, number] {
switch (renderMode) {
case 'classic':
return [absValue, (absValue * 2) % 256, (absValue * 3) % 256];
case 'grayscale':
return [absValue, absValue, absValue];
case 'red':
return [absValue, 0, 0];
case 'green':
return [0, absValue, 0];
case 'blue':
return [0, 0, absValue];
case 'rgb':
return [
((absValue * 255) / 256) | 0,
((((absValue * 2) % 256) * 255) / 256) | 0,
((((absValue * 3) % 256) * 255) / 256) | 0,
];
case 'hsv':
return hsvToRgb(absValue / 255.0, 1.0, 1.0);
case 'rainbow':
return rainbowColor(absValue);
case 'thermal':
return thermalColor(absValue);
case 'neon':
return neonColor(absValue);
case 'cyberpunk':
return cyberpunkColor(absValue);
case 'vaporwave':
return vaporwaveColor(absValue);
case 'dithered':
return ditheredColor(absValue);
case 'palette':
return paletteColor(absValue);
default:
return [absValue, absValue, absValue];
}
}

52
src/utils/constants.ts Normal file
View File

@ -0,0 +1,52 @@
// UI Layout Constants
export const UI_HEIGHTS = {
TOP_BAR: 40,
EDITOR_PANEL: 140,
TOTAL_UI_HEIGHT: 180, // TOP_BAR + EDITOR_PANEL
} as const;
// Performance Constants
export const PERFORMANCE = {
DEFAULT_TILE_SIZE: 64,
MAX_RENDER_TIME_MS: 50,
MAX_SHADER_TIMEOUT_MS: 5,
TIMEOUT_CHECK_INTERVAL: 1000,
MAX_SAVED_SHADERS: 50,
IMAGE_DATA_CACHE_SIZE: 5,
COMPILATION_CACHE_SIZE: 20,
} as const;
// Color Constants
export const COLOR_TABLE_SIZE = 256;
// Storage Keys
export const STORAGE_KEYS = {
SHADERS: 'bitfielder_shaders',
SETTINGS: 'bitfielder_settings',
} as const;
// Value Modes
export const VALUE_MODES = [
'integer',
'float',
'polar',
'distance',
'wave',
'fractal',
'cellular',
'noise',
'warp',
'flow'
] as const;
export type ValueMode = typeof VALUE_MODES[number];
// Default Values
export const DEFAULTS = {
RESOLUTION: 1,
FPS: 30,
RENDER_MODE: 'classic',
VALUE_MODE: 'integer' as ValueMode,
UI_OPACITY: 0.3,
SHADER_CODE: 'x^y',
} as const;

View File

@ -10,10 +10,11 @@
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"strict": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"jsx": "react-jsx"
},
"include": ["src"]
}

View File

@ -1,6 +1,8 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
base: './',
server: {
port: 3000