diff --git a/.claude/settings.local.json b/.claude/settings.local.json index b5c47f5..1bf1978 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -3,9 +3,12 @@ "allow": [ "Bash(dotnet test:*)", "Bash(dotnet build)", - "Bash(dir:*)" + "Bash(dir:*)", + "Bash(node --check:*)", + "Bash(cat:*)", + "Bash(node build.js:*)" ], "deny": [], "ask": [] } -} \ No newline at end of file +} diff --git a/JoshHeaps.Net/Program.cs b/JoshHeaps.Net/Program.cs index 23a8be6..711775e 100644 --- a/JoshHeaps.Net/Program.cs +++ b/JoshHeaps.Net/Program.cs @@ -1,4 +1,5 @@ using JoshHeaps.Net.Hubs; +using JoshHeaps.Net.Services; using JoshHeaps.Net.Services.Implementations; using JoshHeaps.Net.Services.Interfaces; @@ -50,4 +51,7 @@ app.MapControllers(); app.MapHub("/chessHub"); +// Build particle simulator bundle from ES6 modules +ParticleBundler.BuildBundle(app.Environment); + app.Run(); \ No newline at end of file diff --git a/JoshHeaps.Net/Services/ParticleBundler.cs b/JoshHeaps.Net/Services/ParticleBundler.cs new file mode 100644 index 0000000..36ddaae --- /dev/null +++ b/JoshHeaps.Net/Services/ParticleBundler.cs @@ -0,0 +1,72 @@ +using System.Diagnostics; + +namespace JoshHeaps.Net.Services; + +/// +/// Bundles particle simulator ES6 modules into a single file +/// +public static class ParticleBundler +{ + /// + /// Run the particle simulator build script + /// + /// Web host environment for path resolution + /// True if build succeeded, false otherwise + public static bool BuildBundle(IWebHostEnvironment webHostEnvironment) + { + var particlesPath = Path.Combine(webHostEnvironment.WebRootPath, "js", "particles"); + var buildScriptPath = Path.Combine(particlesPath, "build.js"); + + if (!File.Exists(buildScriptPath)) + { + Console.WriteLine($"โš ๏ธ Particle bundler script not found at: {buildScriptPath}"); + return false; + } + + try + { + Console.WriteLine("๐Ÿ”จ Building particle simulator bundle..."); + + var processStartInfo = new ProcessStartInfo + { + FileName = "node", + Arguments = $"\"{buildScriptPath}\"", + WorkingDirectory = particlesPath, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(processStartInfo); + if (process == null) + { + Console.WriteLine("โŒ Failed to start Node.js process"); + return false; + } + + var output = process.StandardOutput.ReadToEnd(); + var error = process.StandardError.ReadToEnd(); + + process.WaitForExit(); + + if (process.ExitCode == 0) + { + Console.WriteLine(output); + return true; + } + else + { + Console.WriteLine("โŒ Particle bundle build failed:"); + Console.WriteLine(error); + return false; + } + } + catch (Exception ex) + { + Console.WriteLine($"โŒ Error building particle bundle: {ex.Message}"); + Console.WriteLine(" Make sure Node.js is installed and available in PATH"); + return false; + } + } +} diff --git a/JoshHeaps.Net/wwwroot/css/particles/page.css b/JoshHeaps.Net/wwwroot/css/particles/page.css index 5277a2c..d2f20c5 100644 --- a/JoshHeaps.Net/wwwroot/css/particles/page.css +++ b/JoshHeaps.Net/wwwroot/css/particles/page.css @@ -1,56 +1,111 @@ -๏ปฟ/* ------- page background + overlays ------- */ +๏ปฟ/* ======================================== + CSS CUSTOM PROPERTIES + ======================================== */ +:root { + /* Spacing system */ + --spacing-xs: 0.35rem; + --spacing-sm: 0.5rem; + --spacing-md: 0.6rem; + --spacing-lg: 0.75rem; + --spacing-xl: 1rem; + + /* Layout constants */ + --fab-size: 44px; + --panel-gap: 12px; + --border-radius: 10px; + --border-radius-sm: 0.4rem; + --border-radius-fab: 8px; + + /* Colors */ + --bg-primary: #000; + --bg-panel: rgba(15, 15, 15, 0.65); + --bg-panel-dark: rgba(11, 11, 11, 0.65); + --bg-button: #1f1f1f; + --bg-button-hover: #2a2a2a; + --text-primary: #e6e6e6; + --border-color: #2a2a2a; + --border-color-dark: #1a1a1a; + + /* Slider colors */ + --slider-track: #444; + --slider-fill: #4cafef; + --slider-thumb: #ff5722; + + /* Z-index layers */ + --z-canvas: 0; + --z-ui: 20; +} + +/* ======================================== + BASE STYLES + ======================================== */ html, body { height: 100%; margin: 0; } body { - background: #000; - color: #e6e6e6; - font-family: ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,Arial,sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif; overflow: hidden; } -/* canvas as full-screen background */ +/* ======================================== + CANVAS + ======================================== */ .sim { position: fixed; - inset: 0; /* top:0 right:0 bottom:0 left:0 */ + inset: 0; width: 100vw; height: 100vh; display: block; - z-index: 0; + z-index: var(--z-canvas); } -/* floating toolbar */ +.container { + position: relative; + height: 100vh; +} + +/* ======================================== + TOOLBAR + ======================================== */ .toolbar { position: fixed; - top: 12px; - left: 12px; - right: 12px; - z-index: 20; + top: var(--panel-gap); + left: var(--panel-gap); + right: var(--panel-gap); + z-index: var(--z-ui); display: flex; - gap: 1rem; + gap: var(--spacing-xl); align-items: center; - padding: .6rem .75rem; - background: rgba(15,15,15,.65); - border: 1px solid #2a2a2a; - border-radius: 10px; + padding: var(--spacing-md) var(--spacing-lg); + background: var(--bg-panel); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); backdrop-filter: blur(6px); } -/* collapse/expand toolbar */ .toolbar .spacer { flex: 1 1 auto; } #toggleToolbar { - padding: .35rem .5rem; - border-radius: .4rem; + padding: var(--spacing-xs) var(--spacing-sm); + border-radius: var(--border-radius-sm); } +/* Collapsed toolbar state (desktop) */ .toolbar.collapsed { - padding: .35rem .5rem; - gap: .5rem; + left: var(--panel-gap); + right: auto; + width: var(--fab-size); + height: var(--fab-size); + padding: 0; + gap: 0; + display: grid; + place-items: center; } .toolbar.collapsed .group, @@ -60,93 +115,150 @@ body { display: none; } -/* floating rules panel (details) */ -.rulepanel { - position: fixed; - left: 12px; - right: 12px; - bottom: 12px; - z-index: 20; - background: rgba(11,11,11,.65); - border: 1px solid #1a1a1a; - border-radius: 10px; - backdrop-filter: blur(6px); - overflow: hidden; /* collapsed state shows only summary */ - max-height: 42vh; /* when open, content scrolls inside */ +.toolbar.collapsed #toggleToolbar { + width: 100%; + height: 100%; + padding: 0; + border-radius: var(--border-radius-fab); + font-size: 0; } -.rulepanel[open] { +.toolbar.collapsed #toggleToolbar::before { + content: "โ˜ฐ"; + font-size: 18px; +} + +/* ======================================== + RULE EDITOR PANEL + ======================================== */ +.rulepanel { + position: fixed; + left: var(--panel-gap); + right: var(--panel-gap); + bottom: var(--panel-gap); + z-index: var(--z-ui); + background: var(--bg-panel-dark); + border: 1px solid var(--border-color-dark); + border-radius: var(--border-radius); + backdrop-filter: blur(6px); + max-height: 42vh; overflow: auto; } +.rulepanel[open] { + max-height: calc(100dvh - 24px - 60px); + overflow: auto; + scrollbar-gutter: stable both-edges; + overscroll-behavior: contain; + scrollbar-width: none; /* Firefox */ +} + +.rulepanel[open]::-webkit-scrollbar { + width: 0; + height: 0; +} + .rulepanel > summary { + position: sticky; + top: 0; + padding: var(--spacing-md) var(--spacing-lg); + margin: 0; cursor: pointer; font-weight: 600; list-style: none; user-select: none; - padding: .6rem .75rem; - margin: 0; - position: sticky; - top: 0; - background: transparent; + border-bottom: 1px solid var(--border-color-dark); display: flex; align-items: center; - gap: .5rem; - border-bottom: 1px solid #1a1a1a; + gap: var(--spacing-sm); } .rulepanel > summary::-webkit-details-marker { display: none; } -.rulepanel > summary::after { +.rulepanel[open] > summary::after { content: "โ–พ"; margin-left: auto; - opacity: .8; + opacity: 0.8; +} + +/* Collapsed rules panel (FAB style) */ +.rulepanel:not([open]) { + left: var(--panel-gap); + right: auto; + width: var(--fab-size); + height: var(--fab-size); + padding: 0; + gap: 0; + display: grid; + place-items: center; +} + +.rulepanel:not([open]) > summary { + border: 0; + padding: 0; + margin: 0; + width: 100%; + height: 100%; + display: grid; + place-items: center; + font-size: 0; } .rulepanel:not([open]) > summary::after { - content: "โ–ธ"; + content: ""; +} + +.rulepanel:not([open]) > summary::before { + content: "Rules"; + font-size: 18px; } .rules-body { - padding: .75rem 1rem 1rem; + padding: var(--spacing-lg) var(--spacing-xl) var(--spacing-xl); display: grid; - gap: .75rem; + gap: var(--spacing-lg); } .rule-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px,1fr)); - gap: .5rem 1rem; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: var(--spacing-sm) var(--spacing-xl); } .rule-item { align-items: center; - gap: .5rem; + gap: var(--spacing-sm); } -/* buttons + inputs (keep your existing styles, just a few tweaks) */ +.rule-actions { + display: flex; + gap: var(--spacing-sm); +} + +.rule-import { + display: grid; + gap: var(--spacing-sm); +} + +/* ======================================== + FORM CONTROLS + ======================================== */ button { - background: #1f1f1f; - border: 1px solid #2a2a2a; - color: #e6e6e6; - padding: .4rem .7rem; - border-radius: .5rem; + background: var(--bg-button); + border: 1px solid var(--border-color); + color: var(--text-primary); + padding: 0.4rem 0.7rem; + border-radius: var(--spacing-sm); cursor: pointer; } button:hover { - background: #2a2a2a; -} - -/* range styling (from your sheet; leave as-is) */ -:root { - --slider-track: #444; - --slider-fill: #4cafef; - --slider-thumb: #ff5722; + background: var(--bg-button-hover); } +/* Range slider styling */ input[type="range"] { -webkit-appearance: none; appearance: none; @@ -193,187 +305,51 @@ input[type="range"]::-moz-range-progress { border-radius: 3px; } -/* container no longer controls layout; keep for semantics */ -.container { - position: relative; - height: 100vh; -} - -/* Canvas already full screen; nothing to change there */ - -/* --- Toolbar square when collapsed --- */ -.toolbar { - position: fixed; - top: 12px; - left: 12px; - right: 12px; - z-index: 20; - background: rgba(15,15,15,.65); - border: 1px solid #2a2a2a; - border-radius: 10px; - backdrop-filter: blur(6px); -} - -.toolbar.collapsed { - left: 12px; - right: auto; /* stop spanning full width */ - width: 44px; - height: 44px; - padding: 0; - gap: 0; - display: grid; - place-items: center; -} - -.toolbar.collapsed .group, -.toolbar.collapsed #restart, -.toolbar.collapsed #permalink, -.toolbar.collapsed .spacer { - display: none; -} - -.toolbar.collapsed #toggleToolbar { - width: 100%; - height: 100%; - padding: 0; - border-radius: 8px; - font-size: 0; /* hide the word "Toolbar" */ -} - -.toolbar.collapsed #toggleToolbar::before { - content: "โ˜ฐ"; /* icon only */ - font-size: 18px; -} - -/* --- Rules panel as a floating card; tiny square when closed --- */ -.rulepanel { - position: fixed; - left: 12px; - right: 12px; - bottom: 12px; - z-index: 20; - background: rgba(11,11,11,.65); - border: 1px solid #1a1a1a; - border-radius: 10px; - backdrop-filter: blur(6px); - max-height: 42vh; - overflow: auto; -} - -.rulepanel[open] { - max-height: calc(100dvh - 24px - 60px); - overflow: auto; - scrollbar-gutter: stable both-edges; /* avoids layout jump */ - overscroll-behavior: contain; -} - -.rulepanel[open] { - scrollbar-width: none; -} -/* Firefox */ -.rulepanel[open]::-webkit-scrollbar { - width: 0; - height: 0; -} -/* WebKit */ - -.rulepanel > summary { - position: sticky; - top: 0; - padding: .6rem .75rem; - margin: 0; - cursor: pointer; - font-weight: 600; - border-bottom: 1px solid #1a1a1a; - display: flex; - align-items: center; - gap: .5rem; -} - -.rulepanel > summary::-webkit-details-marker { - display: none; -} - -.rulepanel[open] > summary::after { - content: "โ–พ"; - margin-left: auto; - opacity: .8; -} - -/* closed => tiny square pinned bottom-right */ -.rulepanel:not([open]) { - left: 12px; - right: auto; /* stop spanning full width */ - width: 44px; - height: 44px; - padding: 0; - gap: 0; - display: grid; - place-items: center; -} - -.rulepanel:not([open]) > summary { - border: 0; - padding: 0; - margin: 0; - width: 100%; - height: 100%; - display: grid; - place-items: center; - font-size: 0; /* hide label text */ -} - -.rulepanel:not([open]) > summary::after { - content: ""; -} -/* no caret */ -.rulepanel:not([open]) > summary::before { - content: "Rules"; /* icon only */ - font-size: 18px; -} - -/* Keep sliders/layout rules you already have */ - +/* ======================================== + MOBILE STYLES + ======================================== */ @media (max-width: 640px) { - /* Default to FAB */ + /* Toolbar defaults to FAB on mobile */ .toolbar { - top: 12px; - left: 12px; - width: 44px; - height: 44px; + top: var(--panel-gap); + left: var(--panel-gap); + width: var(--fab-size); + height: var(--fab-size); padding: 0; gap: 0; display: grid; place-items: center; } - .toolbar .group, .toolbar #restart, .toolbar #permalink, .toolbar .spacer { + .toolbar .group, + .toolbar #restart, + .toolbar #permalink, + .toolbar .spacer { display: none; } - /* FAB button icon */ #toggleToolbar { width: 100%; - height: 44px; + height: var(--fab-size); padding: 0; - border-radius: 8px; + border-radius: var(--border-radius-fab); font-size: 18px; } #toggleToolbar::before { - width: 44px; + width: var(--fab-size); font-size: 18px; } - /* Expanded drawer */ + /* Expanded drawer state on mobile */ .toolbar.mobile.open { - left: 12px; + left: var(--panel-gap); width: min(85vw, 420px); - height: 70vh; /* scroll within drawer if needed */ - padding: .6rem .75rem; + height: 70vh; + padding: var(--spacing-md) var(--spacing-lg); display: flex; flex-direction: column; - gap: .65rem; + gap: 0.65rem; overflow: auto; } @@ -385,7 +361,7 @@ input[type="range"]::-moz-range-progress { .toolbar.mobile.open .group { flex-wrap: wrap; - gap: .5rem; + gap: var(--spacing-sm); } .toolbar.mobile.open input[type="number"], diff --git a/JoshHeaps.Net/wwwroot/js/particles/README.md b/JoshHeaps.Net/wwwroot/js/particles/README.md new file mode 100644 index 0000000..f305164 --- /dev/null +++ b/JoshHeaps.Net/wwwroot/js/particles/README.md @@ -0,0 +1,138 @@ +# Particle Simulator - Modular Architecture + +## Overview + +The particle simulator has been refactored from a single 780-line monolithic file into a clean, modular architecture with separate ES6 modules for each concern. + +## Directory Structure + +``` +particles/ +โ”œโ”€โ”€ src/ Source modules (ES6) +โ”‚ โ”œโ”€โ”€ constants.js Physics & UI constants (40 lines) +โ”‚ โ”œโ”€โ”€ utils.js Utility functions (47 lines) +โ”‚ โ”œโ”€โ”€ random.js PRNG implementation (18 lines) +โ”‚ โ”œโ”€โ”€ colors.js Color palette configuration (16 lines) +โ”‚ โ”œโ”€โ”€ canvas.js Canvas utilities (37 lines) +โ”‚ โ”œโ”€โ”€ particles.js Particle state management (96 lines) +โ”‚ โ”œโ”€โ”€ spatial-grid.js Spatial grid optimization (76 lines) +โ”‚ โ”œโ”€โ”€ rules.js Interaction rules (71 lines) +โ”‚ โ”œโ”€โ”€ physics.js Physics simulation (193 lines) +โ”‚ โ”œโ”€โ”€ renderer.js Rendering logic (35 lines) +โ”‚ โ”œโ”€โ”€ ui/ +โ”‚ โ”‚ โ”œโ”€โ”€ toolbar.js Toolbar responsiveness (34 lines) +โ”‚ โ”‚ โ””โ”€โ”€ rule-editor.js Rule editor controls (72 lines) +โ”‚ โ””โ”€โ”€ main.js Application entry point (213 lines) +โ”œโ”€โ”€ build.js Simple bundler script +โ”œโ”€โ”€ page.bundle.js Built output (auto-generated) +โ””โ”€โ”€ README.md This file +``` + +## Module Responsibilities + +### Core Modules + +- **constants.js** - All configurable physics parameters and constants +- **utils.js** - General-purpose utility functions (formatNumber, wrapPosition, etc.) +- **random.js** - Deterministic pseudo-random number generator (mulberry32) +- **colors.js** - Particle color palette and labels + +### Simulation Modules + +- **particles.js** - Particle state using Structure of Arrays (SoA) pattern +- **spatial-grid.js** - Spatial partitioning for O(1) neighbor queries +- **physics.js** - Force calculations, integration, and collision resolution +- **renderer.js** - Canvas rendering with motion trails +- **rules.js** - Interaction matrix generation and validation + +### UI Modules + +- **canvas.js** - Canvas initialization and DPI scaling +- **ui/toolbar.js** - Responsive toolbar (mobile FAB / desktop bar) +- **ui/rule-editor.js** - Interactive rule editing controls +- **main.js** - Application orchestration and event handling + +## Building + +The project uses a simple custom bundler (`build.js`) that combines all ES6 modules into a single IIFE for browser compatibility. + +### Automatic Build on Startup (Recommended) + +The bundle is **automatically built when the application starts**. The `ParticleBundler` service in `Program.cs` runs the Node.js build script before `app.Run()`. + +When you start the application with `dotnet run`, you'll see: +``` +๐Ÿ”จ Building particle simulator bundle... + โœ“ Processing constants.js + โœ“ Processing utils.js + ... +โœจ Bundle created successfully! +``` + +### Manual Build (Optional) + +You can also manually rebuild the bundle: + +```bash +cd JoshHeaps.Net/wwwroot/js/particles +node build.js +``` + +### Build output: +- All modules are concatenated in dependency order +- Import/export statements are removed +- Code is wrapped in an IIFE (Immediately Invoked Function Expression) +- Result is ~38 KB of clean, minification-ready code + +## Development Workflow + +1. **Edit source modules** in `src/` directory +2. **Start the application**: `dotnet run` (bundle is built automatically) +3. **Test in browser** - The bundle is automatically used by the page +4. **Commit** source modules (bundle is auto-generated, but should be committed too) + +## Benefits of This Architecture + +### ๐ŸŽฏ Single Responsibility +Each module has one clear, focused purpose. No file exceeds 200 lines. + +### ๐Ÿงช Testable +Individual modules can be tested in isolation. + +### ๐Ÿ“– Readable +Easy to navigate and understand. New developers can quickly find what they need. + +### โ™ป๏ธ Reusable +Modules like `spatial-grid.js` or `random.js` can be reused in other projects. + +### ๐Ÿ” Maintainable +Bug fixes and feature additions are localized to specific modules. + +### ๐Ÿ“ฆ Dependency Clarity +Import statements clearly show what each module depends on. + +## Key Design Patterns + +### Structure of Arrays (SoA) +The `particles.js` module uses separate typed arrays for each property (position, velocity, color) instead of an array of particle objects. This improves cache locality and performance. + +### Encapsulation +Internal state is hidden. Modules expose only necessary functions through exports. + +### Separation of Concerns +- Physics logic is completely separate from rendering +- UI controls are separate from simulation logic +- State management is centralized in `particles.js` + +### Dependency Injection +The `main.js` module wires everything together, passing dependencies explicitly. + +## Migration Notes + +This modular architecture maintains 100% compatibility with the original implementation: +- Same visual output +- Same physics behavior +- Same UI/UX +- Same performance characteristics + +The ONLY difference is the code organization, which is dramatically improved. diff --git a/JoshHeaps.Net/wwwroot/js/particles/build.js b/JoshHeaps.Net/wwwroot/js/particles/build.js new file mode 100644 index 0000000..10ffa0e --- /dev/null +++ b/JoshHeaps.Net/wwwroot/js/particles/build.js @@ -0,0 +1,97 @@ +#!/usr/bin/env node + +/** + * Simple ES6 Module Bundler + * Bundles ES6 modules into a single IIFE for browser compatibility + */ + +const fs = require('fs'); +const path = require('path'); + +const srcDir = path.join(__dirname, 'src'); +const outputFile = path.join(__dirname, 'page.bundle.js'); + +// Module dependency order (topological sort) +const moduleOrder = [ + 'constants.js', + 'utils.js', + 'random.js', + 'colors.js', + 'canvas.js', + 'particles.js', + 'spatial-grid.js', + 'rules.js', + 'physics.js', + 'renderer.js', + 'ui/toolbar.js', + 'ui/rule-editor.js', + 'main.js' +]; + +/** + * Read and process a module file + * @param {string} modulePath - Path to module file + * @returns {string} Processed module content + */ +function processModule(modulePath) { + const fullPath = path.join(srcDir, modulePath); + let content = fs.readFileSync(fullPath, 'utf8'); + + // Remove export statements (we'll use direct assignment in IIFE) + content = content.replace(/export\s+(const|let|var|function|class)\s+/g, '$1 '); + content = content.replace(/export\s+\{[^}]+\};?/g, ''); + content = content.replace(/export\s+default\s+/g, ''); + + // Remove import statements (modules are already in order) + content = content.replace(/import\s+\{[^}]+\}\s+from\s+['"][^'"]+['"];?\s*/g, ''); + content = content.replace(/import\s+\*\s+as\s+\w+\s+from\s+['"][^'"]+['"];?\s*/g, ''); + content = content.replace(/import\s+\w+\s+from\s+['"][^'"]+['"];?\s*/g, ''); + + return content; +} + +/** + * Bundle all modules into a single file + */ +function bundle() { + console.log('๐Ÿ”จ Building particle simulator bundle...'); + + let bundledContent = '๏ปฟ(function () {\n "use strict";\n\n'; + + // Process each module in dependency order + for (const modulePath of moduleOrder) { + console.log(` โœ“ Processing ${modulePath}`); + const moduleContent = processModule(modulePath); + bundledContent += ` // ========================================\n`; + bundledContent += ` // MODULE: ${modulePath}\n`; + bundledContent += ` // ========================================\n\n`; + + // Indent module content + const indentedContent = moduleContent + .split('\n') + .map(line => line.length > 0 ? ' ' + line : line) + .join('\n'); + + bundledContent += indentedContent + '\n\n'; + } + + bundledContent += '})();\n'; + + // Write bundle to output file + fs.writeFileSync(outputFile, bundledContent, 'utf8'); + + const stats = fs.statSync(outputFile); + const sizeKB = (stats.size / 1024).toFixed(2); + + console.log(`โœจ Bundle created successfully!`); + console.log(` Output: ${outputFile}`); + console.log(` Size: ${sizeKB} KB`); +} + +// Run bundler +try { + bundle(); +} catch (error) { + console.error('โŒ Build failed:', error.message); + process.exit(1); +} diff --git a/JoshHeaps.Net/wwwroot/js/particles/page.bundle.js b/JoshHeaps.Net/wwwroot/js/particles/page.bundle.js index 17bae5d..34a36fe 100644 --- a/JoshHeaps.Net/wwwroot/js/particles/page.bundle.js +++ b/JoshHeaps.Net/wwwroot/js/particles/page.bundle.js @@ -1,495 +1,1003 @@ ๏ปฟ(function () { "use strict"; - // ===== Utilities ===== - function $(id) { return document.getElementById(id); } + // ======================================== + // MODULE: constants.js + // ======================================== - // Deterministic PRNG (mulberry32) - function mulberry32(seed) { - let t = (seed >>> 0) || 1; + /** + * Physics and UI Constants + * All configurable parameters for the particle simulation + */ + + // ======================================== + // PHYSICS CONSTANTS + // ======================================== + + // Force calculation parameters + const FORCE_SOFTEN = 25; // Softening factor (px^2) to prevent division by zero + const MAX_ACCELERATION = 1.5; // Maximum acceleration magnitude (px/s^2) + const VELOCITY_DAMPING = 0.985; // Velocity damping coefficient per step + const INTERACTION_RANGE = 240; // Max distance for particle interactions (px) + const INTERACTION_RANGE_SQUARED = INTERACTION_RANGE * INTERACTION_RANGE; + + // Rendering parameters + const PARTICLE_RADIUS = 2; // Visual radius of each particle (px) + const TRAIL_ALPHA = 0.1; // Fade alpha for motion trails (lower = longer trails) + + // Interaction rule constraints + const RULE_MIN = -1; // Minimum interaction strength + const RULE_MAX = 1; // Maximum interaction strength + const RULE_SELF_LIMIT = 0.6; // Self-interaction strength limit + const RULE_STABILITY_THRESHOLD = 3; // Max sum of absolute values per row + + // Collision parameters (simple barrier) + const BARRIER_RADIUS = PARTICLE_RADIUS * 2.0; // Collision radius (4px - particles stay separated) + const BARRIER_DIAMETER = BARRIER_RADIUS * 2; // Full barrier diameter (8px) + const BARRIER_RESTITUTION = 0.8; // Bounciness coefficient (0.8 = bouncy) + const BARRIER_FRICTION = 0.001; // Very low friction (smooth motion) + + // Collision solver + const COLLISION_ITERATIONS = 1; // Solver iterations per substep + + // Simulation timing + const FIXED_TIMESTEP = 1 / 60; // 60 FPS physics update + const MAX_FRAME_DELTA = 0.1; // Prevent spiral of death (seconds) + + // Spatial grid optimization + const CELL_SIZE = Math.max(INTERACTION_RANGE, BARRIER_DIAMETER * 2); + + + // ======================================== + // MODULE: utils.js + // ======================================== + + /** + * Utility Functions + * General-purpose helper functions + */ + + /** + * Shorthand for document.getElementById + * @param {string} id - Element ID to retrieve + * @returns {HTMLElement|null} + */ + function getElementById(id) { + return document.getElementById(id); + } + + /** + * Formats a number with sign prefix and 2 decimal places + * @param {number} value - Number to format + * @returns {string} Formatted string like "+0.45" or "-1.23" + */ + function formatNumber(value) { + return (value >= 0 ? "+" : "") + value.toFixed(2); + } + + /** + * Compute toroidal (wrapping) shortest distance between two points + * @param {number} delta - Difference between coordinates + * @param {number} halfSize - Half of the world size in that dimension + * @param {number} worldSize - Full world size in that dimension + * @returns {number} Shortest wrapped distance + */ + function wrapDistance(delta, halfSize, worldSize) { + if (delta > halfSize) return delta - worldSize; + if (delta < -halfSize) return delta + worldSize; + return delta; + } + + /** + * Wrap a coordinate to stay within world bounds (toroidal topology) + * @param {number} position - Position to wrap + * @param {number} worldSize - Size of world dimension + * @returns {number} Wrapped position in [0, worldSize) + */ + function wrapPosition(position, worldSize) { + if (position < 0) return position + worldSize; + if (position >= worldSize) return position - worldSize; + return position; + } + + + // ======================================== + // MODULE: random.js + // ======================================== + + /** + * Pseudo-Random Number Generator + * Deterministic PRNG for reproducible simulations + */ + + /** + * Create a deterministic PRNG using mulberry32 algorithm + * @param {number} seed - Initial seed value + * @returns {function(): number} Random number generator function returning [0, 1) + */ + function createRandomGenerator(seed) { + let state = (seed >>> 0) || 1; return function () { - t += 0x6D2B79F5; - let r = Math.imul(t ^ (t >>> 15), 1 | t); - r ^= r + Math.imul(r ^ (r >>> 7), 61 | r); - return ((r ^ (r >>> 14)) >>> 0) / 4294967296; + state += 0x6D2B79F5; + let random = Math.imul(state ^ (state >>> 15), 1 | state); + random ^= random + Math.imul(random ^ (random >>> 7), 61 | random); + return ((random ^ (random >>> 14)) >>> 0) / 4294967296; }; } - // Colors + palette (R, G, B, Y, C, M) + + // ======================================== + // MODULE: colors.js + // ======================================== + + /** + * Color Configuration + * Particle color palette and naming + */ + const COLOR_NAMES = ["R", "G", "B", "Y", "C", "M"]; + const PALETTE = [ - "#ff0000", // R - "#00ff00", // G - "#0066ff", // B - "#ffff00", // Y - "#00ffff", // C - "#ff00ff" // M + "#ff0000", // Red + "#00ff00", // Green + "#0066ff", // Blue + "#ffff00", // Yellow + "#00ffff", // Cyan + "#ff00ff" // Magenta ]; + const COLOR_LABELS = ["Red", "Green", "Blue", "Yellow", "Cyan", "Magenta"]; - // Build a 6x6 interaction matrix in [-1, +1] - function buildRules(rand) { - const n = COLOR_NAMES.length; - const rules = Array.from({ length: n }, () => Array(n).fill(0)); - for (let i = 0; i < n; i++) { - for (let j = 0; j < n; j++) { - if (i === j) { - // self interaction: mild cohesion/dispersion - rules[i][j] = (rand() * 2 - 1) * 0.6; // [-0.6, 0.6] - } else { - // cross interaction: wider range - rules[i][j] = (rand() * 2 - 1); // [-1, 1] - } - } - } - // stability pass: cap per-row energy - for (let i = 0; i < n; i++) { - const s = rules[i].reduce((a, b) => a + Math.abs(b), 0); - if (s > 3) { - const k = 3 / s; - for (let j = 0; j < n; j++) rules[i][j] *= k; - } - } - return rules; - } - // ===== Canvas helpers ===== - function prepareContext(canvas) { - const ctx = canvas.getContext("2d", { alpha: false }); - resizeToDisplay(canvas, ctx); - return ctx; - } + // ======================================== + // MODULE: canvas.js + // ======================================== - function resizeToDisplay(canvas, ctx) { + /** + * Canvas Utilities + * Canvas setup and resizing functions + */ + + /** + * Prepare canvas context with proper DPI scaling + * @param {HTMLCanvasElement} canvas + * @returns {CanvasRenderingContext2D} + */ + function prepareCanvasContext(canvas) { + const context = canvas.getContext("2d", { alpha: false }); + resizeCanvasToDisplaySize(canvas, context); + return context; + } + + /** + * Resize canvas to match display size with device pixel ratio scaling + * @param {HTMLCanvasElement} canvas + * @param {CanvasRenderingContext2D} context + */ + function resizeCanvasToDisplaySize(canvas, context) { const dpr = window.devicePixelRatio || 1; const rect = canvas.getBoundingClientRect(); - const w = Math.max(1, Math.floor(rect.width * dpr)); - const h = Math.max(1, Math.floor(rect.height * dpr)); - if (canvas.width !== w || canvas.height !== h) { - canvas.width = w; canvas.height = h; + const width = Math.max(1, Math.floor(rect.width * dpr)); + const height = Math.max(1, Math.floor(rect.height * dpr)); + + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; } - // Scale so drawing uses CSS pixels - ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + + // Scale context so we can draw in CSS pixels + context.setTransform(dpr, 0, 0, dpr, 0, 0); } - // ===== Physics constants ===== - const SOFTEN = 25; // px^2 in denominator - const MAX_ACC = 1.5; // acceleration clamp (px/s^2) - const DAMP = 0.985; // velocity damping per step - const RANGE = 240; // interaction cutoff (px); 0 to disable - const RANGE2 = RANGE * RANGE; - const RADIUS = 2; // render radius - const RULE_MIN = -1, RULE_MAX = 1; - // Collisions - const COLLISION_RADIUS = RADIUS; // collide when centers < 2*R - const COLLISION_DIAM = COLLISION_RADIUS * 2; - const COLLISION_ITER = 1; // solver iterations per substep (raise to 2 for tighter packing) - const REST = 0.2; // coefficient of restitution [0..1] - const FRICTION = 0.02; // tangential friction + // ======================================== + // MODULE: particles.js + // ======================================== - // ===== Typed arrays (SoA) ===== - let px, py, vx, vy, col; // Float32Array, Float32Array, Float32Array, Float32Array, Uint8Array - let N = 0; - - function createParticlesTyped(rand, count, width, height) { - px = new Float32Array(count); - py = new Float32Array(count); - vx = new Float32Array(count); - vy = new Float32Array(count); - col = new Uint8Array(count); + /** + * Particle State Management + * Structure of Arrays (SoA) for efficient particle storage + */ + + // ======================================== + // PARTICLE STATE (Structure of Arrays) + // ======================================== + + let particlePositionX; // Float32Array - X positions + let particlePositionY; // Float32Array - Y positions + let particleVelocityX; // Float32Array - X velocities + let particleVelocityY; // Float32Array - Y velocities + let particleColor; // Uint8Array - Color indices + let particleCount = 0; + + /** + * Initialize particle system with random positions and velocities + * @param {function} random - Random number generator + * @param {number} count - Number of particles to create + * @param {number} width - Simulation width + * @param {number} height - Simulation height + */ + function createParticles(random, count, width, height) { + particlePositionX = new Float32Array(count); + particlePositionY = new Float32Array(count); + particleVelocityX = new Float32Array(count); + particleVelocityY = new Float32Array(count); + particleColor = new Uint8Array(count); + for (let i = 0; i < count; i++) { - px[i] = rand() * width; - py[i] = rand() * height; - vx[i] = (rand() - 0.5) * 0.1; - vy[i] = (rand() - 0.5) * 0.1; - col[i] = i % COLOR_NAMES.length; // round-robin for even distribution + particlePositionX[i] = random() * width; + particlePositionY[i] = random() * height; + particleVelocityX[i] = (random() - 0.5) * 0.1; + particleVelocityY[i] = (random() - 0.5) * 0.1; + // Round-robin color assignment for even distribution + particleColor[i] = i % COLOR_NAMES.length; } - N = count; + particleCount = count; + } + + /** + * Get current particle count + * @returns {number} + */ + function getParticleCount() { + return particleCount; + } + + /** + * Get particle position arrays (read-only access) + * @returns {{x: Float32Array, y: Float32Array}} + */ + function getParticlePositions() { + return { + x: particlePositionX, + y: particlePositionY + }; + } + + /** + * Get particle velocity arrays (read-only access) + * @returns {{x: Float32Array, y: Float32Array}} + */ + function getParticleVelocities() { + return { + x: particleVelocityX, + y: particleVelocityY + }; + } + + /** + * Get particle color array (read-only access) + * @returns {Uint8Array} + */ + function getParticleColors() { + return particleColor; + } + + /** + * Update a particle's position + * @param {number} index - Particle index + * @param {number} x - New X position + * @param {number} y - New Y position + */ + function setParticlePosition(index, x, y) { + particlePositionX[index] = x; + particlePositionY[index] = y; + } + + /** + * Update a particle's velocity + * @param {number} index - Particle index + * @param {number} vx - New X velocity + * @param {number} vy - New Y velocity + */ + function setParticleVelocity(index, vx, vy) { + particleVelocityX[index] = vx; + particleVelocityY[index] = vy; } - // ===== Spatial grid (uniform grid / spatial hash) ===== - const CELL_SIZE = Math.max(RANGE, COLLISION_DIAM * 2); // must cover interaction/collision radius - let GRID_W = 0, GRID_H = 0, GRID = []; // array of arrays of indices - function rebuildGrid(W, H) { - GRID_W = Math.ceil(W / CELL_SIZE) | 0; - GRID_H = Math.ceil(H / CELL_SIZE) | 0; - const cells = GRID_W * GRID_H; + // ======================================== + // MODULE: spatial-grid.js + // ======================================== - if (GRID.length !== cells) GRID = Array.from({ length: cells }, () => []); - else for (let i = 0; i < cells; i++) GRID[i].length = 0; - - for (let i = 0; i < N; i++) { - let cx = (Math.floor(px[i] / CELL_SIZE) % GRID_W + GRID_W) % GRID_W; - let cy = (Math.floor(py[i] / CELL_SIZE) % GRID_H + GRID_H) % GRID_H; - GRID[cy * GRID_W + cx].push(i); + /** + * Spatial Grid Optimization + * Uniform spatial grid for efficient O(1) neighbor queries + */ + + let gridWidth = 0; + let gridHeight = 0; + let spatialGrid = []; // Array of arrays containing particle indices + + /** + * Rebuild the spatial grid for the current particle positions + * Enables O(1) neighbor queries instead of O(n^2) + * @param {number} worldWidth - Width of simulation space + * @param {number} worldHeight - Height of simulation space + */ + function rebuildSpatialGrid(worldWidth, worldHeight) { + gridWidth = Math.ceil(worldWidth / CELL_SIZE) | 0; + gridHeight = Math.ceil(worldHeight / CELL_SIZE) | 0; + const totalCells = gridWidth * gridHeight; + + // Initialize or clear grid cells + if (spatialGrid.length !== totalCells) { + spatialGrid = Array.from({ length: totalCells }, () => []); + } else { + for (let i = 0; i < totalCells; i++) { + spatialGrid[i].length = 0; + } + } + + // Assign each particle to its grid cell + const particleCount = getParticleCount(); + const positions = getParticlePositions(); + + for (let i = 0; i < particleCount; i++) { + let cellX = (Math.floor(positions.x[i] / CELL_SIZE) % gridWidth + gridWidth) % gridWidth; + let cellY = (Math.floor(positions.y[i] / CELL_SIZE) % gridHeight + gridHeight) % gridHeight; + spatialGrid[cellY * gridWidth + cellX].push(i); } } - - function forEachNeighborIndices(cx, cy, fn) { - for (let dy = -1; dy <= 1; dy++) { - for (let dx = -1; dx <= 1; dx++) { - const nx = (cx + dx + GRID_W) % GRID_W; - const ny = (cy + dy + GRID_H) % GRID_H; - const cell = GRID[ny * GRID_W + nx]; - for (let k = 0; k < cell.length; k++) fn(cell[k]); + + /** + * Execute callback for all particles in neighboring cells (including current cell) + * @param {number} cellX - X coordinate of center cell + * @param {number} cellY - Y coordinate of center cell + * @param {function(number): void} callback - Function to call with each neighbor particle index + */ + function forEachNeighborParticle(cellX, cellY, callback) { + for (let deltaY = -1; deltaY <= 1; deltaY++) { + for (let deltaX = -1; deltaX <= 1; deltaX++) { + const neighborX = (cellX + deltaX + gridWidth) % gridWidth; + const neighborY = (cellY + deltaY + gridHeight) % gridHeight; + const cell = spatialGrid[neighborY * gridWidth + neighborX]; + for (let k = 0; k < cell.length; k++) { + callback(cell[k]); + } } } } + + /** + * Get the grid cell coordinates for a given position + * @param {number} x - World X coordinate + * @param {number} y - World Y coordinate + * @returns {{cellX: number, cellY: number}} + */ + function getGridCell(x, y) { + return { + cellX: (Math.floor(x / CELL_SIZE) % gridWidth + gridWidth) % gridWidth, + cellY: (Math.floor(y / CELL_SIZE) % gridHeight + gridHeight) % gridHeight + }; + } - // ===== Physics step (typed arrays + grid) ===== - function step(dt, speed, W, H, rules) { - const halfW = W * 0.5, halfH = H * 0.5; - for (let i = 0; i < N; i++) { - let ax = 0, ay = 0; + // ======================================== + // MODULE: rules.js + // ======================================== - const cx = (Math.floor(px[i] / CELL_SIZE) % GRID_W + GRID_W) % GRID_W; - const cy = (Math.floor(py[i] / CELL_SIZE) % GRID_H + GRID_H) % GRID_H; + /** + * Interaction Rules + * Generate and manage particle interaction matrices + */ + + /** + * Build a randomized 6x6 interaction matrix defining how each color affects others + * Self-interactions are limited to [-0.6, 0.6], cross-interactions to [-1, 1] + * Includes stability normalization to prevent explosive behavior + * @param {function} random - Random number generator + * @returns {number[][]} 6x6 matrix of interaction strengths + */ + function buildInteractionRules(random) { + const colorCount = COLOR_NAMES.length; + const rules = Array.from({ length: colorCount }, () => Array(colorCount).fill(0)); + + for (let i = 0; i < colorCount; i++) { + for (let j = 0; j < colorCount; j++) { + if (i === j) { + // Self interaction: mild cohesion/dispersion + rules[i][j] = (random() * 2 - 1) * RULE_SELF_LIMIT; + } else { + // Cross interaction: wider range + rules[i][j] = (random() * 2 - 1); + } + } + } + + // Stability pass: normalize each row to prevent excessive total force + for (let i = 0; i < colorCount; i++) { + const rowSum = rules[i].reduce((sum, value) => sum + Math.abs(value), 0); + if (rowSum > RULE_STABILITY_THRESHOLD) { + const scale = RULE_STABILITY_THRESHOLD / rowSum; + for (let j = 0; j < colorCount; j++) { + rules[i][j] *= scale; + } + } + } + + return rules; + } + + /** + * Validate and clamp imported rules to valid range + * @param {any} importedRules - Rules to validate + * @returns {{valid: boolean, rules?: number[][], error?: string}} + */ + function validateRules(importedRules) { + // Validate shape + if (!Array.isArray(importedRules) || importedRules.length !== 6 || + !importedRules.every(row => Array.isArray(row) && row.length === 6)) { + return { valid: false, error: "Shape 6x6 required" }; + } + + // Clamp values and validate + const clampedRules = Array.from({ length: 6 }, () => Array(6).fill(0)); + for (let i = 0; i < 6; i++) { + for (let j = 0; j < 6; j++) { + const value = +importedRules[i][j]; + if (!Number.isFinite(value)) { + return { valid: false, error: "Non-numeric rule" }; + } + clampedRules[i][j] = Math.max(RULE_MIN, Math.min(RULE_MAX, value)); + } + } + + return { valid: true, rules: clampedRules }; + } - forEachNeighborIndices(cx, cy, (j) => { - if (j === i) return; - let dx = px[j] - px[i]; - let dy = py[j] - py[i]; + // ======================================== + // MODULE: physics.js + // ======================================== - // toroidal shortest vector - if (dx > halfW) dx -= W; else if (dx < -halfW) dx += W; - if (dy > halfH) dy -= H; else if (dy < -halfH) dy += H; - - const d2 = dx * dx + dy * dy; - if (RANGE && d2 > RANGE2) return; - - const k = rules[col[i]][col[j]]; - const inv = 1 / (d2 + SOFTEN); - ax += k * dx * inv; - ay += k * dy * inv; + /** + * Physics Simulation + * Force calculations, integration, and collision resolution + */ + + /** + * Update particle physics for one timestep + * Calculates forces based on interaction rules, integrates motion, and wraps positions + * @param {number} deltaTime - Time step duration (seconds) + * @param {number} speedMultiplier - Speed multiplier from user input + * @param {number} worldWidth - Width of simulation space + * @param {number} worldHeight - Height of simulation space + * @param {number[][]} interactionRules - 6x6 matrix of color interaction strengths + */ + function simulatePhysicsStep(deltaTime, speedMultiplier, worldWidth, worldHeight, interactionRules) { + const halfWidth = worldWidth * 0.5; + const halfHeight = worldHeight * 0.5; + + const particleCount = getParticleCount(); + const positions = getParticlePositions(); + const velocities = getParticleVelocities(); + const colors = getParticleColors(); + + for (let i = 0; i < particleCount; i++) { + let accelerationX = 0; + let accelerationY = 0; + + // Find grid cell for this particle + const { cellX, cellY } = getGridCell(positions.x[i], positions.y[i]); + + // Compute forces from nearby particles + forEachNeighborParticle(cellX, cellY, (j) => { + if (j === i) return; // Skip self + + // Calculate toroidal (wrapping) distance + let deltaX = positions.x[j] - positions.x[i]; + let deltaY = positions.y[j] - positions.y[i]; + + deltaX = wrapDistance(deltaX, halfWidth, worldWidth); + deltaY = wrapDistance(deltaY, halfHeight, worldHeight); + + const distanceSquared = deltaX * deltaX + deltaY * deltaY; + + // Skip if beyond interaction range + if (INTERACTION_RANGE && distanceSquared > INTERACTION_RANGE_SQUARED) return; + + // Apply interaction force: F = k / (r^2 + soften) + const ruleStrength = interactionRules[colors[i]][colors[j]]; + const forceFactor = 1 / (distanceSquared + FORCE_SOFTEN); + accelerationX += ruleStrength * deltaX * forceFactor; + accelerationY += ruleStrength * deltaY * forceFactor; }); - - // clamp acceleration - const a2 = ax * ax + ay * ay; - if (a2 > MAX_ACC * MAX_ACC) { - const s = MAX_ACC / Math.sqrt(a2); - ax *= s; ay *= s; + + // Clamp acceleration magnitude + const accelerationMagnitudeSquared = accelerationX * accelerationX + accelerationY * accelerationY; + if (accelerationMagnitudeSquared > MAX_ACCELERATION * MAX_ACCELERATION) { + const scale = MAX_ACCELERATION / Math.sqrt(accelerationMagnitudeSquared); + accelerationX *= scale; + accelerationY *= scale; } - - // integrate - vx[i] = (vx[i] + ax * dt * speed) * DAMP; - vy[i] = (vy[i] + ay * dt * speed) * DAMP; - px[i] += vx[i] * dt * speed; - py[i] += vy[i] * dt * speed; - - // wrap - if (px[i] < 0) px[i] += W; else if (px[i] >= W) px[i] -= W; - if (py[i] < 0) py[i] += H; else if (py[i] >= H) py[i] -= H; + + // Integrate velocity (with damping) + const newVx = (velocities.x[i] + accelerationX * deltaTime * speedMultiplier) * VELOCITY_DAMPING; + const newVy = (velocities.y[i] + accelerationY * deltaTime * speedMultiplier) * VELOCITY_DAMPING; + setParticleVelocity(i, newVx, newVy); + + // Integrate position + const newPx = positions.x[i] + newVx * deltaTime * speedMultiplier; + const newPy = positions.y[i] + newVy * deltaTime * speedMultiplier; + + // Wrap positions (toroidal world) + setParticlePosition( + i, + wrapPosition(newPx, worldWidth), + wrapPosition(newPy, worldHeight) + ); } } - - // ===== Collision resolver (typed arrays + grid) ===== - function resolveCollisions(W, H) { - const halfW = W * 0.5, halfH = H * 0.5; - const minDist = COLLISION_DIAM; - const minDist2 = minDist * minDist; - - for (let i = 0; i < N; i++) { - const cx = (Math.floor(px[i] / CELL_SIZE) % GRID_W + GRID_W) % GRID_W; - const cy = (Math.floor(py[i] / CELL_SIZE) % GRID_H + GRID_H) % GRID_H; - - forEachNeighborIndices(cx, cy, (j) => { + + /** + * Resolve particle-particle collisions using simple barrier physics + * Particles have a barrier radius that prevents overlap with elastic bouncing + * @param {number} worldWidth - Width of simulation space + * @param {number} worldHeight - Height of simulation space + */ + function resolveCollisions(worldWidth, worldHeight) { + const halfWidth = worldWidth * 0.5; + const halfHeight = worldHeight * 0.5; + const barrierDistanceSquared = BARRIER_DIAMETER * BARRIER_DIAMETER; + + const particleCount = getParticleCount(); + const positions = getParticlePositions(); + const velocities = getParticleVelocities(); + + for (let i = 0; i < particleCount; i++) { + const { cellX, cellY } = getGridCell(positions.x[i], positions.y[i]); + + forEachNeighborParticle(cellX, cellY, (j) => { + // Only process each pair once (i < j) if (j <= i) return; - - let dx = px[j] - px[i]; - let dy = py[j] - py[i]; - if (dx > halfW) dx -= W; else if (dx < -halfW) dx += W; - if (dy > halfH) dy -= H; else if (dy < -halfH) dy += H; - - const d2 = dx * dx + dy * dy; - if (d2 >= minDist2 || d2 === 0) return; - - const dist = Math.sqrt(d2); - const nx = dx / dist, ny = dy / dist; - const overlap = (minDist - dist) * 0.5; - - // separate - px[i] -= nx * overlap; py[i] -= ny * overlap; - px[j] += nx * overlap; py[j] += ny * overlap; - - // wrap after correction - if (px[i] < 0) px[i] += W; else if (px[i] >= W) px[i] -= W; - if (py[i] < 0) py[i] += H; else if (py[i] >= H) py[i] -= H; - if (px[j] < 0) px[j] += W; else if (px[j] >= W) px[j] -= W; - if (py[j] < 0) py[j] += H; else if (py[j] >= H) py[j] -= H; - - // velocity impulse - const rvx = vx[j] - vx[i]; - const rvy = vy[j] - vy[i]; - const vrel = rvx * nx + rvy * ny; - if (vrel < 0) { - const jimp = -(1 + REST) * vrel * 0.5; // equal mass - const jx = jimp * nx, jy = jimp * ny; - vx[i] -= jx; vy[i] -= jy; - vx[j] += jx; vy[j] += jy; - - // tangential friction - const tvx = rvx - vrel * nx; - const tvy = rvy - vrel * ny; - const tmag = Math.hypot(tvx, tvy); - if (tmag > 1e-8) { - const tx = tvx / tmag, ty = tvy / tmag; - const fj = FRICTION * tmag * 0.5; - vx[i] += tx * fj; vy[i] += ty * fj; - vx[j] -= tx * fj; vy[j] -= ty * fj; - } + + // Calculate toroidal distance + let deltaX = positions.x[j] - positions.x[i]; + let deltaY = positions.y[j] - positions.y[i]; + + deltaX = wrapDistance(deltaX, halfWidth, worldWidth); + deltaY = wrapDistance(deltaY, halfHeight, worldHeight); + + const distanceSquared = deltaX * deltaX + deltaY * deltaY; + + // Check if particles are colliding (within barrier distance) + if (distanceSquared >= barrierDistanceSquared || distanceSquared === 0) return; + + const distance = Math.sqrt(distanceSquared); + const normalX = deltaX / distance; + const normalY = deltaY / distance; + + // Position correction: push particles apart to barrier distance + const overlap = BARRIER_DIAMETER - distance; + const correction = overlap * 0.5; + const newPx_i = positions.x[i] - normalX * correction; + const newPy_i = positions.y[i] - normalY * correction; + const newPx_j = positions.x[j] + normalX * correction; + const newPy_j = positions.y[j] + normalY * correction; + + setParticlePosition(i, wrapPosition(newPx_i, worldWidth), wrapPosition(newPy_i, worldHeight)); + setParticlePosition(j, wrapPosition(newPx_j, worldWidth), wrapPosition(newPy_j, worldHeight)); + + // Elastic collision response with restitution + const relativeVelocityX = velocities.x[j] - velocities.x[i]; + const relativeVelocityY = velocities.y[j] - velocities.y[i]; + const normalVelocity = relativeVelocityX * normalX + relativeVelocityY * normalY; + + // Only resolve if particles are approaching + if (normalVelocity < 0) { + const impulse = (1 + BARRIER_RESTITUTION) * normalVelocity * 0.5; + const impulseX = impulse * normalX; + const impulseY = impulse * normalY; + + setParticleVelocity( + i, + velocities.x[i] + impulseX, + velocities.y[i] + impulseY + ); + setParticleVelocity( + j, + velocities.x[j] - impulseX, + velocities.y[j] - impulseY + ); + } + + // Apply minimal friction to tangential velocity + const tangentVelocityX = relativeVelocityX - normalVelocity * normalX; + const tangentVelocityY = relativeVelocityY - normalVelocity * normalY; + const tangentMagnitude = Math.hypot(tangentVelocityX, tangentVelocityY); + + if (tangentMagnitude > 1e-8) { + const tangentX = tangentVelocityX / tangentMagnitude; + const tangentY = tangentVelocityY / tangentMagnitude; + const frictionImpulse = BARRIER_FRICTION * tangentMagnitude * 0.5; + + setParticleVelocity( + i, + velocities.x[i] + tangentX * frictionImpulse, + velocities.y[i] + tangentY * frictionImpulse + ); + setParticleVelocity( + j, + velocities.x[j] - tangentX * frictionImpulse, + velocities.y[j] - tangentY * frictionImpulse + ); } }); } } - // ===== Render (typed arrays) ===== - function render(ctx, W, H) { - // fade to black - ctx.globalAlpha = 0.1; // lower alpha = longer trails - ctx.fillStyle = "#000"; - ctx.fillRect(0, 0, W, H); - ctx.globalAlpha = 1; - for (let i = 0; i < N; i++) { - ctx.fillStyle = PALETTE[col[i]]; - ctx.beginPath(); - ctx.arc(px[i], py[i], RADIUS, 0, Math.PI * 2); - ctx.fill(); + // ======================================== + // MODULE: renderer.js + // ======================================== + + /** + * Rendering + * Canvas rendering with motion trail effects + */ + + /** + * Render all particles to canvas with motion trail effect + * @param {CanvasRenderingContext2D} context - Canvas rendering context + * @param {number} width - Canvas width + * @param {number} height - Canvas height + */ + function renderParticles(context, width, height) { + // Fade previous frame to create motion trails + context.globalAlpha = TRAIL_ALPHA; // Lower alpha = longer trails + context.fillStyle = "#000"; + context.fillRect(0, 0, width, height); + context.globalAlpha = 1; + + // Draw all particles + const particleCount = getParticleCount(); + const positions = getParticlePositions(); + const colors = getParticleColors(); + + for (let i = 0; i < particleCount; i++) { + context.fillStyle = PALETTE[colors[i]]; + context.beginPath(); + context.arc(positions.x[i], positions.y[i], PARTICLE_RADIUS, 0, Math.PI * 2); + context.fill(); } } - // ===== Main bootstrap ===== - function main() { - const boot = window.__PARTICLE_BOOT__ || { seed: 123, n: 600, speed: 1 }; - const canvas = $("sim"); - const ctx = prepareContext(canvas); + // ======================================== + // MODULE: ui/toolbar.js + // ======================================== - const seedInput = $("seed"); - const countInput = $("count"); - const speedRange = $("speed"); - const speedOut = $("speedOut"); - - const restartBtn = $("restart"); - const permalinkBtn = $("permalink"); - const randomSeedBtn = $("randomSeed"); - - // === Rule editor UI === - const activeColorSel = $("ruleActiveColor"); - const sliderIds = ["R", "G", "B", "Y", "C", "M"]; // targets - const sliders = sliderIds.map(id => ({ - input: $("rule_" + id), - out: $("rule_" + id + "_out") - })); - const copyRulesBtn = $("copyRules"); - const toggleImportBtn = $("toggleImport"); - const importArea = $("importArea"); - const rulesJson = $("rulesJson"); - const applyRulesBtn = $("applyRules"); - const cancelImportBtn = $("cancelImport"); - - const toolbarEl = document.querySelector(".toolbar"); - const toggleToolbarBtn = document.getElementById("toggleToolbar"); - - const mq = window.matchMedia("(max-width: 640px)"); + /** + * Toolbar UI + * Responsive toolbar behavior (mobile FAB vs desktop bar) + */ + + /** + * Setup responsive toolbar behavior (mobile FAB vs desktop bar) + * @param {HTMLElement} toolbar + * @param {HTMLElement} toggleButton + */ + function setupToolbarResponsiveness(toolbar, toggleButton) { + const mediaQuery = window.matchMedia("(max-width: 640px)"); + function applyToolbarMode() { - if (mq.matches) { - toolbarEl.classList.add("mobile"); - // In mobile, default closed as FAB - toolbarEl.classList.remove("open", "collapsed"); + if (mediaQuery.matches) { + toolbar.classList.add("mobile"); + toolbar.classList.remove("open", "collapsed"); } else { - toolbarEl.classList.remove("mobile", "open"); - // Desktop keeps your collapsed/expanded behavior + toolbar.classList.remove("mobile", "open"); } } - mq.addEventListener("change", applyToolbarMode); + + mediaQuery.addEventListener("change", applyToolbarMode); applyToolbarMode(); - - toggleToolbarBtn.addEventListener("click", () => { - if (toolbarEl.classList.contains("mobile")) { - toolbarEl.classList.toggle("open"); // mobile: toggle drawer + + toggleButton.addEventListener("click", () => { + if (toolbar.classList.contains("mobile")) { + toolbar.classList.toggle("open"); // Mobile: toggle drawer } else { - toolbarEl.classList.toggle("collapsed"); // desktop: toggle bar + toolbar.classList.toggle("collapsed"); // Desktop: toggle bar } }); + } - // Reflect initial values - seedInput.value = String(boot.seed >>> 0); - countInput.value = String(boot.n); - speedRange.value = String(boot.speed); - speedOut.textContent = speedRange.value; + // ======================================== + // MODULE: ui/rule-editor.js + // ======================================== - // State - let rand, rules; - let W = canvas.clientWidth; - let H = canvas.clientHeight; - - // Fixed-step accumulator - const DT = 1 / 60; // seconds - let acc = 0; - let last = performance.now(); - let running = true; - - function init(seed, n) { - rand = mulberry32(seed >>> 0); - rules = buildRules(rand); - W = canvas.clientWidth; - H = canvas.clientHeight; - createParticlesTyped(rand, n, W, H); - // sync UI with current active row - syncRuleUI(); - } - - function frame(now) { - if (!running) return; - // keep canvas sized & transformed - resizeToDisplay(canvas, ctx); - W = canvas.clientWidth; - H = canvas.clientHeight; - - const speed = +speedRange.value || 1; - - acc += Math.min(0.1, (now - last) / 1000); - last = now; - - while (acc >= DT) { - rebuildGrid(W, H); // build grid for forces - step(DT, speed, W, H, rules); - rebuildGrid(W, H); // positions changed; rebuild for collisions - for (let it = 0; it < COLLISION_ITER; it++) resolveCollisions(W, H); - acc -= DT; - } - - render(ctx, W, H); - requestAnimationFrame(frame); - } - - // UI - speedRange.addEventListener("input", () => { - speedOut.textContent = speedRange.value; - }); - - function fmt(v) { return (v >= 0 ? "+" : "") + v.toFixed(2); } - - function syncRuleUI() { - if (!rules) return; - const src = parseInt(activeColorSel.value, 10) || 0; - for (let t = 0; t < sliders.length; t++) { - const val = Math.max(RULE_MIN, Math.min(RULE_MAX, rules[src][t] ?? 0)); - sliders[t].input.value = String(val); - sliders[t].out.textContent = fmt(val); - } - } - - // Slider change -> update rules matrix live - sliders.forEach((s, tIdx) => { - const color = PALETTE[tIdx]; - s.input.style.setProperty("--slider-thumb", color); - - s.input.addEventListener("input", () => { - const src = parseInt(activeColorSel.value, 10) || 0; - const v = Math.max(RULE_MIN, Math.min(RULE_MAX, parseFloat(s.input.value))); - rules[src][tIdx] = v; - s.out.textContent = fmt(v); + /** + * Rule Editor UI + * Interactive controls for editing particle interaction rules + */ + + /** + * Setup rule editor UI interactions + * @param {Object} elements - UI element references + * @param {function} syncCallback - Callback to sync UI with current rules + * @param {Object} rulesRef - Reference object containing the rules matrix + */ + function setupRuleEditor(elements, syncCallback, rulesRef) { + const { activeColorSelect, sliders, copyButton, toggleImportButton, + importArea, rulesJsonTextarea, applyButton, cancelButton } = elements; + + // Color sliders with live preview + sliders.forEach((slider, targetIndex) => { + const color = PALETTE[targetIndex]; + slider.input.style.setProperty("--slider-thumb", color); + + slider.input.addEventListener("input", () => { + const sourceColor = parseInt(activeColorSelect.value, 10) || 0; + const value = Math.max(RULE_MIN, Math.min(RULE_MAX, parseFloat(slider.input.value))); + rulesRef.rules[sourceColor][targetIndex] = value; + slider.output.textContent = formatNumber(value); }); }); - - activeColorSel.addEventListener("change", syncRuleUI); - - copyRulesBtn.addEventListener("click", async () => { + + // Active color selection + activeColorSelect.addEventListener("change", syncCallback); + + // Copy rules to clipboard + copyButton.addEventListener("click", async () => { try { - await navigator.clipboard.writeText(JSON.stringify(rules)); - copyRulesBtn.textContent = "Copied!"; - setTimeout(() => copyRulesBtn.textContent = "Copy Rules", 800); - } catch { /* ignore */ } + await navigator.clipboard.writeText(JSON.stringify(rulesRef.rules)); + copyButton.textContent = "Copied!"; + setTimeout(() => copyButton.textContent = "Copy Rules", 800); + } catch (error) { + // Clipboard access denied - ignore + } }); - - toggleImportBtn.addEventListener("click", () => { + + // Import/export UI toggle + toggleImportButton.addEventListener("click", () => { importArea.hidden = !importArea.hidden; }); - - cancelImportBtn.addEventListener("click", () => { + + cancelButton.addEventListener("click", () => { importArea.hidden = true; - rulesJson.value = ""; + rulesJsonTextarea.value = ""; }); - - applyRulesBtn.addEventListener("click", () => { + + // Apply imported rules + applyButton.addEventListener("click", () => { try { - const obj = JSON.parse(rulesJson.value); - if (!Array.isArray(obj) || obj.length !== 6 || !obj.every(row => Array.isArray(row) && row.length === 6)) throw new Error("Shape 6x6 required"); - // clamp values and assign - for (let i = 0; i < 6; i++) for (let j = 0; j < 6; j++) { - const v = +obj[i][j]; - if (!Number.isFinite(v)) throw new Error("Non-numeric rule"); - obj[i][j] = Math.max(RULE_MIN, Math.min(RULE_MAX, v)); + const importedRules = JSON.parse(rulesJsonTextarea.value); + const validation = validateRules(importedRules); + + if (!validation.valid) { + throw new Error(validation.error); } - rules = obj; - syncRuleUI(); + + rulesRef.rules = validation.rules; + syncCallback(); importArea.hidden = true; - } catch (e) { - alert("Invalid rules JSON: " + e.message); + } catch (error) { + alert("Invalid rules JSON: " + error.message); } }); + } - // end rules UI - restartBtn.addEventListener("click", () => { - const seed = (seedInput.value === "") ? (boot.seed >>> 0) : (parseInt(seedInput.value, 10) >>> 0); - const n = Math.max(50, Math.min(5000, parseInt(countInput.value, 10) || boot.n)); - init(seed, n); + // ======================================== + // MODULE: main.js + // ======================================== + + /** + * Particle Simulator - Main Application Entry Point + * Initializes and orchestrates the particle simulation system + */ + + /** + * Main application initialization + * Initializes simulation, UI, and event handlers + */ + function initializeApplication() { + const bootConfig = window.__PARTICLE_BOOT__ || { seed: 123, n: 600, speed: 1 }; + + // Canvas setup + const canvas = getElementById("sim"); + const context = prepareCanvasContext(canvas); + + // Input controls + const seedInput = getElementById("seed"); + const countInput = getElementById("count"); + const speedRange = getElementById("speed"); + const speedOutput = getElementById("speedOut"); + + // Action buttons + const restartButton = getElementById("restart"); + const permalinkButton = getElementById("permalink"); + const randomSeedButton = getElementById("randomSeed"); + + // Rule editor elements + const activeColorSelect = getElementById("ruleActiveColor"); + const sliderIds = ["R", "G", "B", "Y", "C", "M"]; + const sliders = sliderIds.map(id => ({ + input: getElementById("rule_" + id), + output: getElementById("rule_" + id + "_out") + })); + const copyRulesButton = getElementById("copyRules"); + const toggleImportButton = getElementById("toggleImport"); + const importArea = getElementById("importArea"); + const rulesJsonTextarea = getElementById("rulesJson"); + const applyRulesButton = getElementById("applyRules"); + const cancelImportButton = getElementById("cancelImport"); + + // Toolbar elements + const toolbarElement = document.querySelector(".toolbar"); + const toggleToolbarButton = getElementById("toggleToolbar"); + + // Initialize input values from boot config + seedInput.value = String(bootConfig.seed >>> 0); + countInput.value = String(bootConfig.n); + speedRange.value = String(bootConfig.speed); + speedOutput.textContent = speedRange.value; + + // Simulation state + let randomGenerator; + let interactionRules; + let worldWidth = canvas.clientWidth; + let worldHeight = canvas.clientHeight; + + // Animation timing (fixed timestep with accumulator) + let timeAccumulator = 0; + let lastFrameTime = performance.now(); + let isRunning = true; + + /** + * Initialize/restart simulation with new parameters + */ + function initializeSimulation(seed, particleCount) { + randomGenerator = createRandomGenerator(seed >>> 0); + interactionRules = buildInteractionRules(randomGenerator); + worldWidth = canvas.clientWidth; + worldHeight = canvas.clientHeight; + createParticles(randomGenerator, particleCount, worldWidth, worldHeight); + syncRuleUI(); + } + + /** + * Sync rule editor UI with current interaction rules + */ + function syncRuleUI() { + if (!interactionRules) return; + + const sourceColor = parseInt(activeColorSelect.value, 10) || 0; + for (let targetColor = 0; targetColor < sliders.length; targetColor++) { + const value = Math.max(RULE_MIN, Math.min(RULE_MAX, + interactionRules[sourceColor][targetColor] ?? 0)); + sliders[targetColor].input.value = String(value); + sliders[targetColor].output.textContent = formatNumber(value); + } + } + + /** + * Main animation frame update loop + */ + function animationFrame(currentTime) { + if (!isRunning) return; + + // Update canvas size + resizeCanvasToDisplaySize(canvas, context); + worldWidth = canvas.clientWidth; + worldHeight = canvas.clientHeight; + + const speedMultiplier = +speedRange.value || 1; + + // Fixed timestep accumulator (prevents spiral of death) + timeAccumulator += Math.min(MAX_FRAME_DELTA, (currentTime - lastFrameTime) / 1000); + lastFrameTime = currentTime; + + // Run physics updates at fixed timestep + while (timeAccumulator >= FIXED_TIMESTEP) { + rebuildSpatialGrid(worldWidth, worldHeight); + simulatePhysicsStep(FIXED_TIMESTEP, speedMultiplier, worldWidth, worldHeight, interactionRules); + + // Rebuild grid after positions changed + rebuildSpatialGrid(worldWidth, worldHeight); + + // Resolve collisions + for (let iteration = 0; iteration < COLLISION_ITERATIONS; iteration++) { + resolveCollisions(worldWidth, worldHeight); + } + + timeAccumulator -= FIXED_TIMESTEP; + } + + renderParticles(context, worldWidth, worldHeight); + requestAnimationFrame(animationFrame); + } + + // ======================================== + // UI EVENT BINDINGS + // ======================================== + + // Speed slider updates output display + speedRange.addEventListener("input", () => { + speedOutput.textContent = speedRange.value; }); - - // ensure rule UI matches after manual seed change without clicking restart - seedInput.addEventListener("change", () => { /* no-op until restart */ }); - - permalinkBtn.addEventListener("click", async () => { + + // Setup toolbar responsiveness + setupToolbarResponsiveness(toolbarElement, toggleToolbarButton); + + // Setup rule editor + const rulesReference = { + get rules() { return interactionRules; }, + set rules(v) { interactionRules = v; } + }; + setupRuleEditor({ + activeColorSelect, + sliders, + copyButton: copyRulesButton, + toggleImportButton, + importArea, + rulesJsonTextarea, + applyButton: applyRulesButton, + cancelButton: cancelImportButton + }, syncRuleUI, rulesReference); + + // Restart simulation button + restartButton.addEventListener("click", () => { + const seed = (seedInput.value === "") ? (bootConfig.seed >>> 0) : + (parseInt(seedInput.value, 10) >>> 0); + const count = Math.max(50, Math.min(5000, parseInt(countInput.value, 10) || bootConfig.n)); + initializeSimulation(seed, count); + }); + + // Permalink button - copy shareable URL + permalinkButton.addEventListener("click", async () => { const url = new URL(location.href); - url.searchParams.set("seed", String(seedInput.value || boot.seed)); - url.searchParams.set("n", String(countInput.value || boot.n)); - url.searchParams.set("speed", String(speedRange.value || boot.speed)); + url.searchParams.set("seed", String(seedInput.value || bootConfig.seed)); + url.searchParams.set("n", String(countInput.value || bootConfig.n)); + url.searchParams.set("speed", String(speedRange.value || bootConfig.speed)); history.replaceState({}, "", url); + try { await navigator.clipboard.writeText(url.toString()); - permalinkBtn.textContent = "Copied!"; - setTimeout(() => permalinkBtn.textContent = "Permalink", 800); - } catch { /* ignore */ } - }); - - randomSeedBtn.addEventListener("click", () => { - // cryptographically strong uint32 - const buf = new Uint32Array(1); - (window.crypto || window.msCrypto).getRandomValues(buf); - seedInput.value = String(buf[0] >>> 0); - }); - - // Pause on tab hide (saves battery) - document.addEventListener("visibilitychange", () => { - running = document.visibilityState !== "hidden"; - if (running) { - last = performance.now(); - requestAnimationFrame(frame); + permalinkButton.textContent = "Copied!"; + setTimeout(() => permalinkButton.textContent = "Permalink", 800); + } catch (error) { + // Clipboard access denied - ignore } }); - - // Initial start - init(boot.seed >>> 0, boot.n); - requestAnimationFrame(frame); - + + // Random seed button + randomSeedButton.addEventListener("click", () => { + const buffer = new Uint32Array(1); + (window.crypto || window.msCrypto).getRandomValues(buffer); + seedInput.value = String(buffer[0] >>> 0); + }); + + // Pause simulation when tab is hidden (battery saving) + document.addEventListener("visibilitychange", () => { + isRunning = document.visibilityState !== "hidden"; + if (isRunning) { + lastFrameTime = performance.now(); + requestAnimationFrame(animationFrame); + } + }); + // Handle window resizes window.addEventListener("resize", () => { - resizeToDisplay(canvas, ctx); + resizeCanvasToDisplaySize(canvas, context); }); + + // Start simulation + initializeSimulation(bootConfig.seed >>> 0, bootConfig.n); + requestAnimationFrame(animationFrame); + } + + // ======================================== + // APPLICATION BOOTSTRAP + // ======================================== + + // Start application when DOM is ready + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initializeApplication, { once: true }); + } else { + initializeApplication(); } - // Start when DOM is ready - if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", main, { once: true }); - } else { - main(); - } -})(); \ No newline at end of file + +})(); diff --git a/JoshHeaps.Net/wwwroot/js/particles/src/canvas.js b/JoshHeaps.Net/wwwroot/js/particles/src/canvas.js new file mode 100644 index 0000000..2656b91 --- /dev/null +++ b/JoshHeaps.Net/wwwroot/js/particles/src/canvas.js @@ -0,0 +1,35 @@ +/** + * Canvas Utilities + * Canvas setup and resizing functions + */ + +/** + * Prepare canvas context with proper DPI scaling + * @param {HTMLCanvasElement} canvas + * @returns {CanvasRenderingContext2D} + */ +export function prepareCanvasContext(canvas) { + const context = canvas.getContext("2d", { alpha: false }); + resizeCanvasToDisplaySize(canvas, context); + return context; +} + +/** + * Resize canvas to match display size with device pixel ratio scaling + * @param {HTMLCanvasElement} canvas + * @param {CanvasRenderingContext2D} context + */ +export function resizeCanvasToDisplaySize(canvas, context) { + const dpr = window.devicePixelRatio || 1; + const rect = canvas.getBoundingClientRect(); + const width = Math.max(1, Math.floor(rect.width * dpr)); + const height = Math.max(1, Math.floor(rect.height * dpr)); + + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + } + + // Scale context so we can draw in CSS pixels + context.setTransform(dpr, 0, 0, dpr, 0, 0); +} diff --git a/JoshHeaps.Net/wwwroot/js/particles/src/colors.js b/JoshHeaps.Net/wwwroot/js/particles/src/colors.js new file mode 100644 index 0000000..621a248 --- /dev/null +++ b/JoshHeaps.Net/wwwroot/js/particles/src/colors.js @@ -0,0 +1,17 @@ +/** + * Color Configuration + * Particle color palette and naming + */ + +export const COLOR_NAMES = ["R", "G", "B", "Y", "C", "M"]; + +export const PALETTE = [ + "#ff0000", // Red + "#00ff00", // Green + "#0066ff", // Blue + "#ffff00", // Yellow + "#00ffff", // Cyan + "#ff00ff" // Magenta +]; + +export const COLOR_LABELS = ["Red", "Green", "Blue", "Yellow", "Cyan", "Magenta"]; diff --git a/JoshHeaps.Net/wwwroot/js/particles/src/constants.js b/JoshHeaps.Net/wwwroot/js/particles/src/constants.js new file mode 100644 index 0000000..4760781 --- /dev/null +++ b/JoshHeaps.Net/wwwroot/js/particles/src/constants.js @@ -0,0 +1,41 @@ +/** + * Physics and UI Constants + * All configurable parameters for the particle simulation + */ + +// ======================================== +// PHYSICS CONSTANTS +// ======================================== + +// Force calculation parameters +export const FORCE_SOFTEN = 25; // Softening factor (px^2) to prevent division by zero +export const MAX_ACCELERATION = 1.5; // Maximum acceleration magnitude (px/s^2) +export const VELOCITY_DAMPING = 0.985; // Velocity damping coefficient per step +export const INTERACTION_RANGE = 240; // Max distance for particle interactions (px) +export const INTERACTION_RANGE_SQUARED = INTERACTION_RANGE * INTERACTION_RANGE; + +// Rendering parameters +export const PARTICLE_RADIUS = 2; // Visual radius of each particle (px) +export const TRAIL_ALPHA = 0.1; // Fade alpha for motion trails (lower = longer trails) + +// Interaction rule constraints +export const RULE_MIN = -1; // Minimum interaction strength +export const RULE_MAX = 1; // Maximum interaction strength +export const RULE_SELF_LIMIT = 0.6; // Self-interaction strength limit +export const RULE_STABILITY_THRESHOLD = 3; // Max sum of absolute values per row + +// Collision parameters (simple barrier) +export const BARRIER_RADIUS = PARTICLE_RADIUS * 2.0; // Collision radius (4px - particles stay separated) +export const BARRIER_DIAMETER = BARRIER_RADIUS * 2; // Full barrier diameter (8px) +export const BARRIER_RESTITUTION = 0.8; // Bounciness coefficient (0.8 = bouncy) +export const BARRIER_FRICTION = 0.001; // Very low friction (smooth motion) + +// Collision solver +export const COLLISION_ITERATIONS = 1; // Solver iterations per substep + +// Simulation timing +export const FIXED_TIMESTEP = 1 / 60; // 60 FPS physics update +export const MAX_FRAME_DELTA = 0.1; // Prevent spiral of death (seconds) + +// Spatial grid optimization +export const CELL_SIZE = Math.max(INTERACTION_RANGE, BARRIER_DIAMETER * 2); diff --git a/JoshHeaps.Net/wwwroot/js/particles/src/main.js b/JoshHeaps.Net/wwwroot/js/particles/src/main.js new file mode 100644 index 0000000..1f2c272 --- /dev/null +++ b/JoshHeaps.Net/wwwroot/js/particles/src/main.js @@ -0,0 +1,233 @@ +/** + * Particle Simulator - Main Application Entry Point + * Initializes and orchestrates the particle simulation system + */ + +import { + FIXED_TIMESTEP, + MAX_FRAME_DELTA, + COLLISION_ITERATIONS, + RULE_MIN, + RULE_MAX +} from './constants.js'; +import { getElementById, formatNumber } from './utils.js'; +import { createRandomGenerator } from './random.js'; +import { prepareCanvasContext, resizeCanvasToDisplaySize } from './canvas.js'; +import { createParticles } from './particles.js'; +import { rebuildSpatialGrid } from './spatial-grid.js'; +import { simulatePhysicsStep, resolveCollisions } from './physics.js'; +import { renderParticles } from './renderer.js'; +import { buildInteractionRules } from './rules.js'; +import { setupToolbarResponsiveness } from './ui/toolbar.js'; +import { setupRuleEditor } from './ui/rule-editor.js'; + +/** + * Main application initialization + * Initializes simulation, UI, and event handlers + */ +function initializeApplication() { + const bootConfig = window.__PARTICLE_BOOT__ || { seed: 123, n: 600, speed: 1 }; + + // Canvas setup + const canvas = getElementById("sim"); + const context = prepareCanvasContext(canvas); + + // Input controls + const seedInput = getElementById("seed"); + const countInput = getElementById("count"); + const speedRange = getElementById("speed"); + const speedOutput = getElementById("speedOut"); + + // Action buttons + const restartButton = getElementById("restart"); + const permalinkButton = getElementById("permalink"); + const randomSeedButton = getElementById("randomSeed"); + + // Rule editor elements + const activeColorSelect = getElementById("ruleActiveColor"); + const sliderIds = ["R", "G", "B", "Y", "C", "M"]; + const sliders = sliderIds.map(id => ({ + input: getElementById("rule_" + id), + output: getElementById("rule_" + id + "_out") + })); + const copyRulesButton = getElementById("copyRules"); + const toggleImportButton = getElementById("toggleImport"); + const importArea = getElementById("importArea"); + const rulesJsonTextarea = getElementById("rulesJson"); + const applyRulesButton = getElementById("applyRules"); + const cancelImportButton = getElementById("cancelImport"); + + // Toolbar elements + const toolbarElement = document.querySelector(".toolbar"); + const toggleToolbarButton = getElementById("toggleToolbar"); + + // Initialize input values from boot config + seedInput.value = String(bootConfig.seed >>> 0); + countInput.value = String(bootConfig.n); + speedRange.value = String(bootConfig.speed); + speedOutput.textContent = speedRange.value; + + // Simulation state + let randomGenerator; + let interactionRules; + let worldWidth = canvas.clientWidth; + let worldHeight = canvas.clientHeight; + + // Animation timing (fixed timestep with accumulator) + let timeAccumulator = 0; + let lastFrameTime = performance.now(); + let isRunning = true; + + /** + * Initialize/restart simulation with new parameters + */ + function initializeSimulation(seed, particleCount) { + randomGenerator = createRandomGenerator(seed >>> 0); + interactionRules = buildInteractionRules(randomGenerator); + worldWidth = canvas.clientWidth; + worldHeight = canvas.clientHeight; + createParticles(randomGenerator, particleCount, worldWidth, worldHeight); + syncRuleUI(); + } + + /** + * Sync rule editor UI with current interaction rules + */ + function syncRuleUI() { + if (!interactionRules) return; + + const sourceColor = parseInt(activeColorSelect.value, 10) || 0; + for (let targetColor = 0; targetColor < sliders.length; targetColor++) { + const value = Math.max(RULE_MIN, Math.min(RULE_MAX, + interactionRules[sourceColor][targetColor] ?? 0)); + sliders[targetColor].input.value = String(value); + sliders[targetColor].output.textContent = formatNumber(value); + } + } + + /** + * Main animation frame update loop + */ + function animationFrame(currentTime) { + if (!isRunning) return; + + // Update canvas size + resizeCanvasToDisplaySize(canvas, context); + worldWidth = canvas.clientWidth; + worldHeight = canvas.clientHeight; + + const speedMultiplier = +speedRange.value || 1; + + // Fixed timestep accumulator (prevents spiral of death) + timeAccumulator += Math.min(MAX_FRAME_DELTA, (currentTime - lastFrameTime) / 1000); + lastFrameTime = currentTime; + + // Run physics updates at fixed timestep + while (timeAccumulator >= FIXED_TIMESTEP) { + rebuildSpatialGrid(worldWidth, worldHeight); + simulatePhysicsStep(FIXED_TIMESTEP, speedMultiplier, worldWidth, worldHeight, interactionRules); + + // Rebuild grid after positions changed + rebuildSpatialGrid(worldWidth, worldHeight); + + // Resolve collisions + for (let iteration = 0; iteration < COLLISION_ITERATIONS; iteration++) { + resolveCollisions(worldWidth, worldHeight); + } + + timeAccumulator -= FIXED_TIMESTEP; + } + + renderParticles(context, worldWidth, worldHeight); + requestAnimationFrame(animationFrame); + } + + // ======================================== + // UI EVENT BINDINGS + // ======================================== + + // Speed slider updates output display + speedRange.addEventListener("input", () => { + speedOutput.textContent = speedRange.value; + }); + + // Setup toolbar responsiveness + setupToolbarResponsiveness(toolbarElement, toggleToolbarButton); + + // Setup rule editor + const rulesReference = { + get rules() { return interactionRules; }, + set rules(v) { interactionRules = v; } + }; + setupRuleEditor({ + activeColorSelect, + sliders, + copyButton: copyRulesButton, + toggleImportButton, + importArea, + rulesJsonTextarea, + applyButton: applyRulesButton, + cancelButton: cancelImportButton + }, syncRuleUI, rulesReference); + + // Restart simulation button + restartButton.addEventListener("click", () => { + const seed = (seedInput.value === "") ? (bootConfig.seed >>> 0) : + (parseInt(seedInput.value, 10) >>> 0); + const count = Math.max(50, Math.min(5000, parseInt(countInput.value, 10) || bootConfig.n)); + initializeSimulation(seed, count); + }); + + // Permalink button - copy shareable URL + permalinkButton.addEventListener("click", async () => { + const url = new URL(location.href); + url.searchParams.set("seed", String(seedInput.value || bootConfig.seed)); + url.searchParams.set("n", String(countInput.value || bootConfig.n)); + url.searchParams.set("speed", String(speedRange.value || bootConfig.speed)); + history.replaceState({}, "", url); + + try { + await navigator.clipboard.writeText(url.toString()); + permalinkButton.textContent = "Copied!"; + setTimeout(() => permalinkButton.textContent = "Permalink", 800); + } catch (error) { + // Clipboard access denied - ignore + } + }); + + // Random seed button + randomSeedButton.addEventListener("click", () => { + const buffer = new Uint32Array(1); + (window.crypto || window.msCrypto).getRandomValues(buffer); + seedInput.value = String(buffer[0] >>> 0); + }); + + // Pause simulation when tab is hidden (battery saving) + document.addEventListener("visibilitychange", () => { + isRunning = document.visibilityState !== "hidden"; + if (isRunning) { + lastFrameTime = performance.now(); + requestAnimationFrame(animationFrame); + } + }); + + // Handle window resizes + window.addEventListener("resize", () => { + resizeCanvasToDisplaySize(canvas, context); + }); + + // Start simulation + initializeSimulation(bootConfig.seed >>> 0, bootConfig.n); + requestAnimationFrame(animationFrame); +} + +// ======================================== +// APPLICATION BOOTSTRAP +// ======================================== + +// Start application when DOM is ready +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initializeApplication, { once: true }); +} else { + initializeApplication(); +} diff --git a/JoshHeaps.Net/wwwroot/js/particles/src/particles.js b/JoshHeaps.Net/wwwroot/js/particles/src/particles.js new file mode 100644 index 0000000..145ba20 --- /dev/null +++ b/JoshHeaps.Net/wwwroot/js/particles/src/particles.js @@ -0,0 +1,102 @@ +/** + * Particle State Management + * Structure of Arrays (SoA) for efficient particle storage + */ + +import { COLOR_NAMES } from './colors.js'; + +// ======================================== +// PARTICLE STATE (Structure of Arrays) +// ======================================== + +let particlePositionX; // Float32Array - X positions +let particlePositionY; // Float32Array - Y positions +let particleVelocityX; // Float32Array - X velocities +let particleVelocityY; // Float32Array - Y velocities +let particleColor; // Uint8Array - Color indices +let particleCount = 0; + +/** + * Initialize particle system with random positions and velocities + * @param {function} random - Random number generator + * @param {number} count - Number of particles to create + * @param {number} width - Simulation width + * @param {number} height - Simulation height + */ +export function createParticles(random, count, width, height) { + particlePositionX = new Float32Array(count); + particlePositionY = new Float32Array(count); + particleVelocityX = new Float32Array(count); + particleVelocityY = new Float32Array(count); + particleColor = new Uint8Array(count); + + for (let i = 0; i < count; i++) { + particlePositionX[i] = random() * width; + particlePositionY[i] = random() * height; + particleVelocityX[i] = (random() - 0.5) * 0.1; + particleVelocityY[i] = (random() - 0.5) * 0.1; + // Round-robin color assignment for even distribution + particleColor[i] = i % COLOR_NAMES.length; + } + particleCount = count; +} + +/** + * Get current particle count + * @returns {number} + */ +export function getParticleCount() { + return particleCount; +} + +/** + * Get particle position arrays (read-only access) + * @returns {{x: Float32Array, y: Float32Array}} + */ +export function getParticlePositions() { + return { + x: particlePositionX, + y: particlePositionY + }; +} + +/** + * Get particle velocity arrays (read-only access) + * @returns {{x: Float32Array, y: Float32Array}} + */ +export function getParticleVelocities() { + return { + x: particleVelocityX, + y: particleVelocityY + }; +} + +/** + * Get particle color array (read-only access) + * @returns {Uint8Array} + */ +export function getParticleColors() { + return particleColor; +} + +/** + * Update a particle's position + * @param {number} index - Particle index + * @param {number} x - New X position + * @param {number} y - New Y position + */ +export function setParticlePosition(index, x, y) { + particlePositionX[index] = x; + particlePositionY[index] = y; +} + +/** + * Update a particle's velocity + * @param {number} index - Particle index + * @param {number} vx - New X velocity + * @param {number} vy - New Y velocity + */ +export function setParticleVelocity(index, vx, vy) { + particleVelocityX[index] = vx; + particleVelocityY[index] = vy; +} diff --git a/JoshHeaps.Net/wwwroot/js/particles/src/physics.js b/JoshHeaps.Net/wwwroot/js/particles/src/physics.js new file mode 100644 index 0000000..2466635 --- /dev/null +++ b/JoshHeaps.Net/wwwroot/js/particles/src/physics.js @@ -0,0 +1,197 @@ +/** + * Physics Simulation + * Force calculations, integration, and collision resolution + */ + +import { + FORCE_SOFTEN, + MAX_ACCELERATION, + VELOCITY_DAMPING, + INTERACTION_RANGE, + INTERACTION_RANGE_SQUARED, + BARRIER_RADIUS, + BARRIER_DIAMETER, + BARRIER_RESTITUTION, + BARRIER_FRICTION +} from './constants.js'; +import { wrapDistance, wrapPosition } from './utils.js'; +import { + getParticleCount, + getParticlePositions, + getParticleVelocities, + getParticleColors, + setParticlePosition, + setParticleVelocity +} from './particles.js'; +import { getGridCell, forEachNeighborParticle } from './spatial-grid.js'; + +/** + * Update particle physics for one timestep + * Calculates forces based on interaction rules, integrates motion, and wraps positions + * @param {number} deltaTime - Time step duration (seconds) + * @param {number} speedMultiplier - Speed multiplier from user input + * @param {number} worldWidth - Width of simulation space + * @param {number} worldHeight - Height of simulation space + * @param {number[][]} interactionRules - 6x6 matrix of color interaction strengths + */ +export function simulatePhysicsStep(deltaTime, speedMultiplier, worldWidth, worldHeight, interactionRules) { + const halfWidth = worldWidth * 0.5; + const halfHeight = worldHeight * 0.5; + + const particleCount = getParticleCount(); + const positions = getParticlePositions(); + const velocities = getParticleVelocities(); + const colors = getParticleColors(); + + for (let i = 0; i < particleCount; i++) { + let accelerationX = 0; + let accelerationY = 0; + + // Find grid cell for this particle + const { cellX, cellY } = getGridCell(positions.x[i], positions.y[i]); + + // Compute forces from nearby particles + forEachNeighborParticle(cellX, cellY, (j) => { + if (j === i) return; // Skip self + + // Calculate toroidal (wrapping) distance + let deltaX = positions.x[j] - positions.x[i]; + let deltaY = positions.y[j] - positions.y[i]; + + deltaX = wrapDistance(deltaX, halfWidth, worldWidth); + deltaY = wrapDistance(deltaY, halfHeight, worldHeight); + + const distanceSquared = deltaX * deltaX + deltaY * deltaY; + + // Skip if beyond interaction range + if (INTERACTION_RANGE && distanceSquared > INTERACTION_RANGE_SQUARED) return; + + // Apply interaction force: F = k / (r^2 + soften) + const ruleStrength = interactionRules[colors[i]][colors[j]]; + const forceFactor = 1 / (distanceSquared + FORCE_SOFTEN); + accelerationX += ruleStrength * deltaX * forceFactor; + accelerationY += ruleStrength * deltaY * forceFactor; + }); + + // Clamp acceleration magnitude + const accelerationMagnitudeSquared = accelerationX * accelerationX + accelerationY * accelerationY; + if (accelerationMagnitudeSquared > MAX_ACCELERATION * MAX_ACCELERATION) { + const scale = MAX_ACCELERATION / Math.sqrt(accelerationMagnitudeSquared); + accelerationX *= scale; + accelerationY *= scale; + } + + // Integrate velocity (with damping) + const newVx = (velocities.x[i] + accelerationX * deltaTime * speedMultiplier) * VELOCITY_DAMPING; + const newVy = (velocities.y[i] + accelerationY * deltaTime * speedMultiplier) * VELOCITY_DAMPING; + setParticleVelocity(i, newVx, newVy); + + // Integrate position + const newPx = positions.x[i] + newVx * deltaTime * speedMultiplier; + const newPy = positions.y[i] + newVy * deltaTime * speedMultiplier; + + // Wrap positions (toroidal world) + setParticlePosition( + i, + wrapPosition(newPx, worldWidth), + wrapPosition(newPy, worldHeight) + ); + } +} + +/** + * Resolve particle-particle collisions using simple barrier physics + * Particles have a barrier radius that prevents overlap with elastic bouncing + * @param {number} worldWidth - Width of simulation space + * @param {number} worldHeight - Height of simulation space + */ +export function resolveCollisions(worldWidth, worldHeight) { + const halfWidth = worldWidth * 0.5; + const halfHeight = worldHeight * 0.5; + const barrierDistanceSquared = BARRIER_DIAMETER * BARRIER_DIAMETER; + + const particleCount = getParticleCount(); + const positions = getParticlePositions(); + const velocities = getParticleVelocities(); + + for (let i = 0; i < particleCount; i++) { + const { cellX, cellY } = getGridCell(positions.x[i], positions.y[i]); + + forEachNeighborParticle(cellX, cellY, (j) => { + // Only process each pair once (i < j) + if (j <= i) return; + + // Calculate toroidal distance + let deltaX = positions.x[j] - positions.x[i]; + let deltaY = positions.y[j] - positions.y[i]; + + deltaX = wrapDistance(deltaX, halfWidth, worldWidth); + deltaY = wrapDistance(deltaY, halfHeight, worldHeight); + + const distanceSquared = deltaX * deltaX + deltaY * deltaY; + + // Check if particles are colliding (within barrier distance) + if (distanceSquared >= barrierDistanceSquared || distanceSquared === 0) return; + + const distance = Math.sqrt(distanceSquared); + const normalX = deltaX / distance; + const normalY = deltaY / distance; + + // Position correction: push particles apart to barrier distance + const overlap = BARRIER_DIAMETER - distance; + const correction = overlap * 0.5; + const newPx_i = positions.x[i] - normalX * correction; + const newPy_i = positions.y[i] - normalY * correction; + const newPx_j = positions.x[j] + normalX * correction; + const newPy_j = positions.y[j] + normalY * correction; + + setParticlePosition(i, wrapPosition(newPx_i, worldWidth), wrapPosition(newPy_i, worldHeight)); + setParticlePosition(j, wrapPosition(newPx_j, worldWidth), wrapPosition(newPy_j, worldHeight)); + + // Elastic collision response with restitution + const relativeVelocityX = velocities.x[j] - velocities.x[i]; + const relativeVelocityY = velocities.y[j] - velocities.y[i]; + const normalVelocity = relativeVelocityX * normalX + relativeVelocityY * normalY; + + // Only resolve if particles are approaching + if (normalVelocity < 0) { + const impulse = (1 + BARRIER_RESTITUTION) * normalVelocity * 0.5; + const impulseX = impulse * normalX; + const impulseY = impulse * normalY; + + setParticleVelocity( + i, + velocities.x[i] + impulseX, + velocities.y[i] + impulseY + ); + setParticleVelocity( + j, + velocities.x[j] - impulseX, + velocities.y[j] - impulseY + ); + } + + // Apply minimal friction to tangential velocity + const tangentVelocityX = relativeVelocityX - normalVelocity * normalX; + const tangentVelocityY = relativeVelocityY - normalVelocity * normalY; + const tangentMagnitude = Math.hypot(tangentVelocityX, tangentVelocityY); + + if (tangentMagnitude > 1e-8) { + const tangentX = tangentVelocityX / tangentMagnitude; + const tangentY = tangentVelocityY / tangentMagnitude; + const frictionImpulse = BARRIER_FRICTION * tangentMagnitude * 0.5; + + setParticleVelocity( + i, + velocities.x[i] + tangentX * frictionImpulse, + velocities.y[i] + tangentY * frictionImpulse + ); + setParticleVelocity( + j, + velocities.x[j] - tangentX * frictionImpulse, + velocities.y[j] - tangentY * frictionImpulse + ); + } + }); + } +} diff --git a/JoshHeaps.Net/wwwroot/js/particles/src/random.js b/JoshHeaps.Net/wwwroot/js/particles/src/random.js new file mode 100644 index 0000000..17106c9 --- /dev/null +++ b/JoshHeaps.Net/wwwroot/js/particles/src/random.js @@ -0,0 +1,19 @@ +/** + * Pseudo-Random Number Generator + * Deterministic PRNG for reproducible simulations + */ + +/** + * Create a deterministic PRNG using mulberry32 algorithm + * @param {number} seed - Initial seed value + * @returns {function(): number} Random number generator function returning [0, 1) + */ +export function createRandomGenerator(seed) { + let state = (seed >>> 0) || 1; + return function () { + state += 0x6D2B79F5; + let random = Math.imul(state ^ (state >>> 15), 1 | state); + random ^= random + Math.imul(random ^ (random >>> 7), 61 | random); + return ((random ^ (random >>> 14)) >>> 0) / 4294967296; + }; +} diff --git a/JoshHeaps.Net/wwwroot/js/particles/src/renderer.js b/JoshHeaps.Net/wwwroot/js/particles/src/renderer.js new file mode 100644 index 0000000..d56941c --- /dev/null +++ b/JoshHeaps.Net/wwwroot/js/particles/src/renderer.js @@ -0,0 +1,34 @@ +/** + * Rendering + * Canvas rendering with motion trail effects + */ + +import { PARTICLE_RADIUS, TRAIL_ALPHA } from './constants.js'; +import { PALETTE } from './colors.js'; +import { getParticleCount, getParticlePositions, getParticleColors } from './particles.js'; + +/** + * Render all particles to canvas with motion trail effect + * @param {CanvasRenderingContext2D} context - Canvas rendering context + * @param {number} width - Canvas width + * @param {number} height - Canvas height + */ +export function renderParticles(context, width, height) { + // Fade previous frame to create motion trails + context.globalAlpha = TRAIL_ALPHA; // Lower alpha = longer trails + context.fillStyle = "#000"; + context.fillRect(0, 0, width, height); + context.globalAlpha = 1; + + // Draw all particles + const particleCount = getParticleCount(); + const positions = getParticlePositions(); + const colors = getParticleColors(); + + for (let i = 0; i < particleCount; i++) { + context.fillStyle = PALETTE[colors[i]]; + context.beginPath(); + context.arc(positions.x[i], positions.y[i], PARTICLE_RADIUS, 0, Math.PI * 2); + context.fill(); + } +} diff --git a/JoshHeaps.Net/wwwroot/js/particles/src/rules.js b/JoshHeaps.Net/wwwroot/js/particles/src/rules.js new file mode 100644 index 0000000..b79211c --- /dev/null +++ b/JoshHeaps.Net/wwwroot/js/particles/src/rules.js @@ -0,0 +1,71 @@ +/** + * Interaction Rules + * Generate and manage particle interaction matrices + */ + +import { COLOR_NAMES } from './colors.js'; +import { RULE_MIN, RULE_MAX, RULE_SELF_LIMIT, RULE_STABILITY_THRESHOLD } from './constants.js'; + +/** + * Build a randomized 6x6 interaction matrix defining how each color affects others + * Self-interactions are limited to [-0.6, 0.6], cross-interactions to [-1, 1] + * Includes stability normalization to prevent explosive behavior + * @param {function} random - Random number generator + * @returns {number[][]} 6x6 matrix of interaction strengths + */ +export function buildInteractionRules(random) { + const colorCount = COLOR_NAMES.length; + const rules = Array.from({ length: colorCount }, () => Array(colorCount).fill(0)); + + for (let i = 0; i < colorCount; i++) { + for (let j = 0; j < colorCount; j++) { + if (i === j) { + // Self interaction: mild cohesion/dispersion + rules[i][j] = (random() * 2 - 1) * RULE_SELF_LIMIT; + } else { + // Cross interaction: wider range + rules[i][j] = (random() * 2 - 1); + } + } + } + + // Stability pass: normalize each row to prevent excessive total force + for (let i = 0; i < colorCount; i++) { + const rowSum = rules[i].reduce((sum, value) => sum + Math.abs(value), 0); + if (rowSum > RULE_STABILITY_THRESHOLD) { + const scale = RULE_STABILITY_THRESHOLD / rowSum; + for (let j = 0; j < colorCount; j++) { + rules[i][j] *= scale; + } + } + } + + return rules; +} + +/** + * Validate and clamp imported rules to valid range + * @param {any} importedRules - Rules to validate + * @returns {{valid: boolean, rules?: number[][], error?: string}} + */ +export function validateRules(importedRules) { + // Validate shape + if (!Array.isArray(importedRules) || importedRules.length !== 6 || + !importedRules.every(row => Array.isArray(row) && row.length === 6)) { + return { valid: false, error: "Shape 6x6 required" }; + } + + // Clamp values and validate + const clampedRules = Array.from({ length: 6 }, () => Array(6).fill(0)); + for (let i = 0; i < 6; i++) { + for (let j = 0; j < 6; j++) { + const value = +importedRules[i][j]; + if (!Number.isFinite(value)) { + return { valid: false, error: "Non-numeric rule" }; + } + clampedRules[i][j] = Math.max(RULE_MIN, Math.min(RULE_MAX, value)); + } + } + + return { valid: true, rules: clampedRules }; +} diff --git a/JoshHeaps.Net/wwwroot/js/particles/src/spatial-grid.js b/JoshHeaps.Net/wwwroot/js/particles/src/spatial-grid.js new file mode 100644 index 0000000..442eda3 --- /dev/null +++ b/JoshHeaps.Net/wwwroot/js/particles/src/spatial-grid.js @@ -0,0 +1,74 @@ +/** + * Spatial Grid Optimization + * Uniform spatial grid for efficient O(1) neighbor queries + */ + +import { CELL_SIZE } from './constants.js'; +import { getParticleCount, getParticlePositions } from './particles.js'; + +let gridWidth = 0; +let gridHeight = 0; +let spatialGrid = []; // Array of arrays containing particle indices + +/** + * Rebuild the spatial grid for the current particle positions + * Enables O(1) neighbor queries instead of O(n^2) + * @param {number} worldWidth - Width of simulation space + * @param {number} worldHeight - Height of simulation space + */ +export function rebuildSpatialGrid(worldWidth, worldHeight) { + gridWidth = Math.ceil(worldWidth / CELL_SIZE) | 0; + gridHeight = Math.ceil(worldHeight / CELL_SIZE) | 0; + const totalCells = gridWidth * gridHeight; + + // Initialize or clear grid cells + if (spatialGrid.length !== totalCells) { + spatialGrid = Array.from({ length: totalCells }, () => []); + } else { + for (let i = 0; i < totalCells; i++) { + spatialGrid[i].length = 0; + } + } + + // Assign each particle to its grid cell + const particleCount = getParticleCount(); + const positions = getParticlePositions(); + + for (let i = 0; i < particleCount; i++) { + let cellX = (Math.floor(positions.x[i] / CELL_SIZE) % gridWidth + gridWidth) % gridWidth; + let cellY = (Math.floor(positions.y[i] / CELL_SIZE) % gridHeight + gridHeight) % gridHeight; + spatialGrid[cellY * gridWidth + cellX].push(i); + } +} + +/** + * Execute callback for all particles in neighboring cells (including current cell) + * @param {number} cellX - X coordinate of center cell + * @param {number} cellY - Y coordinate of center cell + * @param {function(number): void} callback - Function to call with each neighbor particle index + */ +export function forEachNeighborParticle(cellX, cellY, callback) { + for (let deltaY = -1; deltaY <= 1; deltaY++) { + for (let deltaX = -1; deltaX <= 1; deltaX++) { + const neighborX = (cellX + deltaX + gridWidth) % gridWidth; + const neighborY = (cellY + deltaY + gridHeight) % gridHeight; + const cell = spatialGrid[neighborY * gridWidth + neighborX]; + for (let k = 0; k < cell.length; k++) { + callback(cell[k]); + } + } + } +} + +/** + * Get the grid cell coordinates for a given position + * @param {number} x - World X coordinate + * @param {number} y - World Y coordinate + * @returns {{cellX: number, cellY: number}} + */ +export function getGridCell(x, y) { + return { + cellX: (Math.floor(x / CELL_SIZE) % gridWidth + gridWidth) % gridWidth, + cellY: (Math.floor(y / CELL_SIZE) % gridHeight + gridHeight) % gridHeight + }; +} diff --git a/JoshHeaps.Net/wwwroot/js/particles/src/ui/rule-editor.js b/JoshHeaps.Net/wwwroot/js/particles/src/ui/rule-editor.js new file mode 100644 index 0000000..f5ffabc --- /dev/null +++ b/JoshHeaps.Net/wwwroot/js/particles/src/ui/rule-editor.js @@ -0,0 +1,75 @@ +/** + * Rule Editor UI + * Interactive controls for editing particle interaction rules + */ + +import { PALETTE } from '../colors.js'; +import { RULE_MIN, RULE_MAX } from '../constants.js'; +import { formatNumber } from '../utils.js'; +import { validateRules } from '../rules.js'; + +/** + * Setup rule editor UI interactions + * @param {Object} elements - UI element references + * @param {function} syncCallback - Callback to sync UI with current rules + * @param {Object} rulesRef - Reference object containing the rules matrix + */ +export function setupRuleEditor(elements, syncCallback, rulesRef) { + const { activeColorSelect, sliders, copyButton, toggleImportButton, + importArea, rulesJsonTextarea, applyButton, cancelButton } = elements; + + // Color sliders with live preview + sliders.forEach((slider, targetIndex) => { + const color = PALETTE[targetIndex]; + slider.input.style.setProperty("--slider-thumb", color); + + slider.input.addEventListener("input", () => { + const sourceColor = parseInt(activeColorSelect.value, 10) || 0; + const value = Math.max(RULE_MIN, Math.min(RULE_MAX, parseFloat(slider.input.value))); + rulesRef.rules[sourceColor][targetIndex] = value; + slider.output.textContent = formatNumber(value); + }); + }); + + // Active color selection + activeColorSelect.addEventListener("change", syncCallback); + + // Copy rules to clipboard + copyButton.addEventListener("click", async () => { + try { + await navigator.clipboard.writeText(JSON.stringify(rulesRef.rules)); + copyButton.textContent = "Copied!"; + setTimeout(() => copyButton.textContent = "Copy Rules", 800); + } catch (error) { + // Clipboard access denied - ignore + } + }); + + // Import/export UI toggle + toggleImportButton.addEventListener("click", () => { + importArea.hidden = !importArea.hidden; + }); + + cancelButton.addEventListener("click", () => { + importArea.hidden = true; + rulesJsonTextarea.value = ""; + }); + + // Apply imported rules + applyButton.addEventListener("click", () => { + try { + const importedRules = JSON.parse(rulesJsonTextarea.value); + const validation = validateRules(importedRules); + + if (!validation.valid) { + throw new Error(validation.error); + } + + rulesRef.rules = validation.rules; + syncCallback(); + importArea.hidden = true; + } catch (error) { + alert("Invalid rules JSON: " + error.message); + } + }); +} diff --git a/JoshHeaps.Net/wwwroot/js/particles/src/ui/toolbar.js b/JoshHeaps.Net/wwwroot/js/particles/src/ui/toolbar.js new file mode 100644 index 0000000..cabfbee --- /dev/null +++ b/JoshHeaps.Net/wwwroot/js/particles/src/ui/toolbar.js @@ -0,0 +1,33 @@ +/** + * Toolbar UI + * Responsive toolbar behavior (mobile FAB vs desktop bar) + */ + +/** + * Setup responsive toolbar behavior (mobile FAB vs desktop bar) + * @param {HTMLElement} toolbar + * @param {HTMLElement} toggleButton + */ +export function setupToolbarResponsiveness(toolbar, toggleButton) { + const mediaQuery = window.matchMedia("(max-width: 640px)"); + + function applyToolbarMode() { + if (mediaQuery.matches) { + toolbar.classList.add("mobile"); + toolbar.classList.remove("open", "collapsed"); + } else { + toolbar.classList.remove("mobile", "open"); + } + } + + mediaQuery.addEventListener("change", applyToolbarMode); + applyToolbarMode(); + + toggleButton.addEventListener("click", () => { + if (toolbar.classList.contains("mobile")) { + toolbar.classList.toggle("open"); // Mobile: toggle drawer + } else { + toolbar.classList.toggle("collapsed"); // Desktop: toggle bar + } + }); +} diff --git a/JoshHeaps.Net/wwwroot/js/particles/src/utils.js b/JoshHeaps.Net/wwwroot/js/particles/src/utils.js new file mode 100644 index 0000000..12b7558 --- /dev/null +++ b/JoshHeaps.Net/wwwroot/js/particles/src/utils.js @@ -0,0 +1,47 @@ +/** + * Utility Functions + * General-purpose helper functions + */ + +/** + * Shorthand for document.getElementById + * @param {string} id - Element ID to retrieve + * @returns {HTMLElement|null} + */ +export function getElementById(id) { + return document.getElementById(id); +} + +/** + * Formats a number with sign prefix and 2 decimal places + * @param {number} value - Number to format + * @returns {string} Formatted string like "+0.45" or "-1.23" + */ +export function formatNumber(value) { + return (value >= 0 ? "+" : "") + value.toFixed(2); +} + +/** + * Compute toroidal (wrapping) shortest distance between two points + * @param {number} delta - Difference between coordinates + * @param {number} halfSize - Half of the world size in that dimension + * @param {number} worldSize - Full world size in that dimension + * @returns {number} Shortest wrapped distance + */ +export function wrapDistance(delta, halfSize, worldSize) { + if (delta > halfSize) return delta - worldSize; + if (delta < -halfSize) return delta + worldSize; + return delta; +} + +/** + * Wrap a coordinate to stay within world bounds (toroidal topology) + * @param {number} position - Position to wrap + * @param {number} worldSize - Size of world dimension + * @returns {number} Wrapped position in [0, worldSize) + */ +export function wrapPosition(position, worldSize) { + if (position < 0) return position + worldSize; + if (position >= worldSize) return position - worldSize; + return position; +}