Compare commits
1
Commits
master
...
AddProject
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a8b3e8447 |
@@ -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": []
|
||||
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user