This commit is contained in:
Josh-Heaps
2025-10-22 14:04:37 -06:00
parent 9969c6e3cf
commit 3a8b3e8447
20 changed files with 2402 additions and 626 deletions
+4 -1
View File
@@ -3,7 +3,10 @@
"allow": [
"Bash(dotnet test:*)",
"Bash(dotnet build)",
"Bash(dir:*)"
"Bash(dir:*)",
"Bash(node --check:*)",
"Bash(cat:*)",
"Bash(node build.js:*)"
],
"deny": [],
"ask": []
+4
View File
@@ -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>("/chessHub");
// Build particle simulator bundle from ES6 modules
ParticleBundler.BuildBundle(app.Environment);
app.Run();
+72
View File
@@ -0,0 +1,72 @@
using System.Diagnostics;
namespace JoshHeaps.Net.Services;
/// <summary>
/// Bundles particle simulator ES6 modules into a single file
/// </summary>
public static class ParticleBundler
{
/// <summary>
/// Run the particle simulator build script
/// </summary>
/// <param name="webHostEnvironment">Web host environment for path resolution</param>
/// <returns>True if build succeeded, false otherwise</returns>
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;
}
}
}
+196 -220
View File
@@ -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;
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;
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"],
@@ -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.
@@ -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);
}
File diff suppressed because it is too large Load Diff
@@ -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);
}
@@ -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"];
@@ -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);
@@ -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();
}
@@ -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;
}
@@ -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
);
}
});
}
}
@@ -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;
};
}
@@ -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();
}
}
@@ -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 };
}
@@ -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
};
}
@@ -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);
}
});
}
@@ -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
}
});
}
@@ -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;
}