Author SHA1 Message Date
Josh-Heaps 52d468a46e Update blog css 2026-03-07 12:26:58 -07:00
Josh-Heaps ea60ee5a4b fix css 2026-03-06 17:15:51 -07:00
Josh-Heaps 99a9474ef7 Add cache invalidation 2026-03-06 17:05:48 -07:00
Josh-HeapsandClaude Opus 4.6 8043eee4cc Replace file-based blog with API-backed service
BlogService now fetches from Media.JoshHeaps.Net API via HttpClient
with in-memory caching (5-min TTL) and stale cache fallback.
Removed Markdig/YamlDotNet dependencies. All page models now async.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-06 15:33:51 -07:00
Josh Heaps 5fd3a5d7c1 Merge pull request #12 from JoshHeaps/AddProject
Add cloud storage to projects section of front page
2025-10-22 11:58:33 -06:00
44 changed files with 1826 additions and 2656 deletions
+2 -3
View File
@@ -4,9 +4,8 @@
"Bash(dotnet test:*)", "Bash(dotnet test:*)",
"Bash(dotnet build)", "Bash(dotnet build)",
"Bash(dir:*)", "Bash(dir:*)",
"Bash(node --check:*)", "Bash(dotnet add:*)",
"Bash(cat:*)", "Bash(dotnet build:*)"
"Bash(node build.js:*)"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []
@@ -0,0 +1,24 @@
---
title: My First Post
date: 2026-03-06
summary: Welcome to my blog! Here I'll share thoughts on software development, projects I'm working on, and things I find interesting.
tags: [dev, personal]
---
# Welcome to My Blog
I've been meaning to start writing about the things I build and learn, and I'm finally getting around to it.
## What to Expect
I plan to write about:
- **Projects** I'm working on, like this website and the other things on my portfolio
- **Software development** tips and patterns I find useful
- **Problem solving** approaches that have helped me grow as a developer
## Why a Blog?
Building things is great, but explaining *how* and *why* you built them is just as valuable. Writing forces you to organize your thoughts, and hopefully someone else finds it useful along the way.
Stay tuned for more posts!
@@ -0,0 +1,20 @@
using JoshHeaps.Net.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace JoshHeaps.Net.Controllers;
[ApiController]
[Route("api/[controller]")]
public class BlogController(IBlogService blogService, IConfiguration configuration) : ControllerBase
{
[HttpPost("invalidate")]
public IActionResult InvalidateCache([FromHeader(Name = "X-Invalidate-Key")] string? key)
{
var expectedKey = configuration["BlogApi:InvalidateKey"];
if (string.IsNullOrEmpty(expectedKey) || key != expectedKey)
return Unauthorized();
blogService.ClearCache();
return Ok();
}
}
+12
View File
@@ -0,0 +1,12 @@
namespace JoshHeaps.Net.Models;
public class BlogPost
{
public long Id { get; set; }
public string Slug { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public DateTime PublishedDate { get; set; }
public string Summary { get; set; } = string.Empty;
public List<string> Tags { get; set; } = [];
public string HtmlContent { get; set; } = string.Empty;
}
+49
View File
@@ -0,0 +1,49 @@
@page
@model JoshHeaps.Net.Pages.BlogModel
@{
ViewData["Title"] = "Blog - JoshHeaps.Net";
Layout = "_Layout";
}
<div class="blog-index">
<h1 class="blog-title">Blog</h1>
@if (Model.ActiveTag is not null)
{
<div class="blog-filter">
<span>Filtered by: <strong>@Model.ActiveTag</strong></span>
<a href="/blog">Clear filter</a>
</div>
}
@if (Model.Posts.Count == 0)
{
<p class="blog-empty">No posts yet. Check back soon!</p>
}
<div class="blog-list">
@foreach (var post in Model.Posts)
{
<article class="blog-card">
<a href="/blog/@post.Slug" class="blog-card-link">
<h2 class="blog-card-title">@post.Title</h2>
</a>
<time class="blog-card-date" datetime="@post.PublishedDate.ToString("yyyy-MM-dd")">
@post.PublishedDate.ToString("MMMM d, yyyy")
</time>
<p class="blog-card-summary">@post.Summary</p>
<div class="blog-card-tags">
@foreach (var tag in post.Tags)
{
<a href="/blog?tag=@tag" class="blog-tag">@tag</a>
}
</div>
</article>
}
</div>
</div>
@section Styles {
<link href="/css/variables.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
<link href="/css/blog/index.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
}
+26
View File
@@ -0,0 +1,26 @@
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Interfaces;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace JoshHeaps.Net.Pages;
public class BlogModel : PageModel
{
private readonly IBlogService _blogService;
public List<BlogPost> Posts { get; set; } = [];
public string? ActiveTag { get; set; }
public BlogModel(IBlogService blogService)
{
_blogService = blogService;
}
public async Task OnGetAsync(string? tag)
{
ActiveTag = tag;
Posts = tag is not null
? await _blogService.GetPostsByTagAsync(tag)
: await _blogService.GetAllPostsAsync();
}
}
+38
View File
@@ -0,0 +1,38 @@
@page "/blog/{slug}"
@model JoshHeaps.Net.Pages.Blog.PostModel
@{
ViewData["Title"] = Model.Post!.Title + " - JoshHeaps.Net";
Layout = "_Layout";
}
<article class="blog-post">
<a href="/blog" class="blog-back">&larr; Back to blog</a>
<header class="blog-post-header">
<h1 class="blog-post-title">@Model.Post.Title</h1>
<time class="blog-post-date" datetime="@Model.Post.PublishedDate.ToString("yyyy-MM-dd")">
@Model.Post.PublishedDate.ToString("MMMM d, yyyy")
</time>
<div class="blog-post-tags">
@foreach (var tag in Model.Post.Tags)
{
<a href="/blog?tag=@tag" class="blog-tag">@tag</a>
}
</div>
</header>
<div class="post-content">
@Html.Raw(Model.Post.HtmlContent)
</div>
</article>
@section Styles {
<link href="/css/variables.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
<link href="/css/blog/post.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
<link href="/css/blog/prism-tomorrow.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
}
@section Scripts {
<script src="https://cdn.jsdelivr.net/npm/prismjs@1/components/prism-core.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/prismjs@1/plugins/autoloader/prism-autoloader.min.js"></script>
}
+28
View File
@@ -0,0 +1,28 @@
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace JoshHeaps.Net.Pages.Blog;
public class PostModel : PageModel
{
private readonly IBlogService _blogService;
public BlogPost? Post { get; set; }
public PostModel(IBlogService blogService)
{
_blogService = blogService;
}
public async Task<IActionResult> OnGetAsync(string slug)
{
Post = await _blogService.GetPostBySlugAsync(slug);
if (Post is null)
return NotFound();
return Page();
}
}
+84 -84
View File
@@ -1,115 +1,115 @@
@page @page
@model JoshHeaps.Net.Pages.IndexModel @model JoshHeaps.Net.Pages.IndexModel
@{ @{
ViewData["Title"] = "JoshHeaps.Net"; ViewData["Title"] = "JoshHeaps.Net";
Layout = "_Layout"; Layout = "_Layout";
} }
<header id="Header"> <nav id="Header">
<a class="headerOption" onclick="showClickedContents('projects')">./ Projects</a> <span class="site-name">josh heaps</span>
<a class="headerOption" onclick="showClickedContents('demos')">./ Demos</a> <div class="nav-links">
<a class="headerOption" onclick="showClickedContents('contact')">./ Contact me</a> <a class="nav-link" onclick="showClickedContents('projects')">Projects</a>
</header> <a class="nav-link" onclick="showClickedContents('demos')">Demos</a>
<a class="nav-link" onclick="showClickedContents('blog')">Blog</a>
<div id="WelcomeMessage"></div> <a class="nav-link" onclick="showClickedContents('contact')">Contact</a>
<div id="ProjectsBox">
<div id="ChessProject" class="projectContainer">
<div class="displayBox diagonal-section-left">
<a target="_blank" href="https://github.com/JoshHeaps/JoshHeaps.Net/blob/master/JoshHeaps.Net/Pages/Chess.cshtml">
<img id="ChessImage" class="projectImage projectImageLeft" src="/images/Chess.jpg" title="Chessboard"/>
</a>
</div>
<div id="ChessText" class="projectTextRight">
<h2 id="ChessTitle" class="projectHeaderRight projectHeader">Chess</h2>
<p id="ChessDescription" class="projectDescriptionRight projectDescription">
As someone who loves chess, I wanted to create a chess game that I could play with my friends and family. It was made using .NET razor pages, a .NET web api backend, and a good amount of javascript on the frontend. Feel free to check it out in the demos below :)
</p>
</div>
</div> </div>
</nav>
<div id="CompilerProject" class="projectContainer"> <section id="Hero">
<div id="CompilerText" class="projectTextLeft"> <div id="WelcomeMessage"></div>
<h2 id="CompilerTitle" class="projectHeaderLeft projectHeader">Compiler</h2> </section>
<p id="CompilerDescription" class="projectDescriptionLeft projectDescription">
I love solving problems, and figuring out how things work. What better way to do both than writing a compiler myself? This project is a compiler that I wrote in C# on .NET 8.0. It takes in a simple language that I created, outputs the corresponding common intermediate language (CIL), and executes it. I plan to add more features in the future. <section id="ProjectsBox">
</p> <h2 class="section-title">Projects</h2>
</div> <div class="projects-grid">
<div class="displayBox diagonal-section-right"> <article class="project-card">
<a target="_blank" href="https://github.com/JoshHeaps/CILCompiler"> <a href="https://github.com/JoshHeaps/JoshHeaps.Net/blob/master/JoshHeaps.Net/Pages/Chess.cshtml" target="_blank" class="project-image-link">
<img id="CompilerImage" class="projectImage projectImageRight" src="/images/CompilerDemo.png" title="Compiler Input and Output"/> <img class="projectImage" src="/images/Chess.jpg" alt="Chessboard" />
</a> </a>
</div> <div class="project-info">
</div> <h3 class="project-title">Chess</h3>
<p class="project-description">As someone who loves chess, I wanted to create a chess game that I could play with my friends and family. It was made using .NET razor pages, a .NET web api backend, and a good amount of javascript on the frontend. Feel free to check it out in the demos below :)</p>
<a class="project-link" href="https://github.com/JoshHeaps/JoshHeaps.Net/blob/master/JoshHeaps.Net/Pages/Chess.cshtml" target="_blank">View on GitHub &rarr;</a>
</div>
</article>
<div id="CloudStorageProject" class="projectContainer"> <article class="project-card">
<div class="displayBox diagonal-section-left"> <a href="https://github.com/JoshHeaps/CILCompiler" target="_blank" class="project-image-link">
<a target="_blank" href="https://media.joshheaps.net"> <img class="projectImage" src="/images/CompilerDemo.png" alt="Compiler Input and Output" />
<img id="CloudStorageImage" class="projectImage projectImageLeft" src="/images/CloudStorageDemo.png" title="CloudStorageScreenshot" />
</a> </a>
</div> <div class="project-info">
<div id="CloudStorageText" class="projectTextRight"> <h3 class="project-title">Compiler</h3>
<h2 id="CloudStorageTitle" class="projectHeaderRight projectHeader">Image Cloud Storage</h2> <p class="project-description">I love solving problems, and figuring out how things work. What better way to do both than writing a compiler myself? This project is a compiler that I wrote in C# on .NET 8.0. It takes in a simple language that I created, outputs the corresponding common intermediate language (CIL), and executes it. I plan to add more features in the future.</p>
<p id="CloudStorageDescription" class="projectDescriptionRight projectDescription"> <a class="project-link" href="https://github.com/JoshHeaps/CILCompiler" target="_blank">View on GitHub &rarr;</a>
My wife and I LOVE to take pictures and videos of our kids, but we do not like paying for extra cloud storage. I made this for my family so we can upload pictures to my server, share them with each other, and now we don't need to worry about how much space we're using. </div>
</p> </article>
</div>
</div>
</div>
<div id="DemosBox" class="displayBox"> <article class="project-card">
<h2 id="DemoHeader">Demos</h2> <a href="https://media.joshheaps.net" target="_blank" class="project-image-link">
<img class="projectImage" src="/images/CloudStorageDemo.png" alt="Cloud Storage Screenshot" />
</a>
<div class="project-info">
<h3 class="project-title">Image Cloud Storage</h3>
<p class="project-description">My wife and I LOVE to take pictures and videos of our kids, but we do not like paying for extra cloud storage. I made this for my family so we can upload pictures to my server, share them with each other, and now we don't need to worry about how much space we're using.</p>
<a class="project-link" href="https://media.joshheaps.net" target="_blank">View Live Demo &rarr;</a>
</div>
</article>
</div>
</section>
<section id="DemosBox">
<h2 class="section-title">Demos</h2>
<div id="buttonWrapper"> <div id="buttonWrapper">
<button id="ChessDemo" class="demoButton" onclick="window.location.href='/chess'"> <button class="demoButton" onclick="window.location.href='/chess'">Play Chess</button>
Play Chess <button class="demoButton" onclick="window.location.href='/particles'">Particle Simulator</button>
</button> <button class="demoButton" onclick="window.location.href='/memorylane'">Memory Lane</button>
<button id="ParticleDemo" class="demoButton" onclick="window.location.href='/particles'"> <button class="demoButton" onclick="window.location.href='https://media.joshheaps.net'">Cloud Image Storage</button>
Particle Simulator <button id="MoreFiller" class="demoButton">More coming soon...</button>
</button>
<button id="MemoryLane" class="demoButton" onclick="window.location.href='/memorylane'">
Memory Lane
</button>
<button id="CloadStorageDemo" class="demoButton" onclick="window.location.href='https://media.joshheaps.net'">
Cloud Image Storage
</button>
<button id="MoreFiller" class="demoButton">
More coming soon...
</button>
</div> </div>
</div> </section>
<section id="BlogBox">
<h2 class="section-title">Latest Posts</h2>
<div id="blogPostsList">
@foreach (var post in Model.LatestPosts)
{
<div class="blog-teaser-item">
<a class="blog-teaser-title" href="/blog/@post.Slug">@post.Title</a>
<span class="blog-teaser-date">@post.PublishedDate.ToString("MMMM d, yyyy")</span>
<p class="blog-teaser-summary">@post.Summary</p>
</div>
}
</div>
<a class="blog-view-all" href="/blog">View all posts &rarr;</a>
</section>
<footer id="Contacts"> <footer id="Contacts">
<svg viewBox="0 0 16 16" class="social-icon"> <a class="social-link" href="https://github.com/JoshHeaps" target="_blank">
<a id="GithubLink" class="social-link" href="https://github.com/JoshHeaps" target="_blank"> <svg viewBox="0 0 16 16" class="social-icon">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"></path> <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"></path>
<rect width="100%" height="100%" fill="transparent" /> </svg>
</a> </a>
</svg> <a class="social-link" href="https://www.linkedin.com/in/josh-heaps/" target="_blank">
<svg viewBox="0 0 24 24" class="social-icon">
<svg viewBox="0 0 24 24" class="social-icon">
<a id="LinkedinLink" class="social-link" href="https://www.linkedin.com/in/josh-heaps/" target="_blank">
<path d="M20.5 2h-17A1.5 1.5 0 002 3.5v17A1.5 1.5 0 003.5 22h17a1.5 1.5 0 001.5-1.5v-17A1.5 1.5 0 0020.5 2zM8 19H5v-9h3zM6.5 8.25A1.75 1.75 0 118.3 6.5a1.78 1.78 0 01-1.8 1.75zM19 19h-3v-4.74c0-1.42-.6-1.93-1.38-1.93A1.74 1.74 0 0013 14.19a.66.66 0 000 .14V19h-3v-9h2.9v1.3a3.11 3.11 0 012.7-1.4c1.55 0 3.36.86 3.36 3.66z"></path> <path d="M20.5 2h-17A1.5 1.5 0 002 3.5v17A1.5 1.5 0 003.5 22h17a1.5 1.5 0 001.5-1.5v-17A1.5 1.5 0 0020.5 2zM8 19H5v-9h3zM6.5 8.25A1.75 1.75 0 118.3 6.5a1.78 1.78 0 01-1.8 1.75zM19 19h-3v-4.74c0-1.42-.6-1.93-1.38-1.93A1.74 1.74 0 0013 14.19a.66.66 0 000 .14V19h-3v-9h2.9v1.3a3.11 3.11 0 012.7-1.4c1.55 0 3.36.86 3.36 3.66z"></path>
<rect width="100%" height="100%" fill="transparent" /> </svg>
</a> </a>
</svg> <a class="social-link" href="mailto:[email protected]">
<svg viewBox="0 -4 31 31" class="social-icon" preserveAspectRatio="none">
<svg viewBox="0 -4 31 31" class="social-icon" preserveAspectRatio="none">
<a id="GmailLink" class="social-link" href="mailto:[email protected]">
<path d="m0 3.23863636v2.72727273l3.12784091 3.02727273 3.69034091 2.08636368.68181818-4.59034095-.68181818-4.27329546-1.90909091-1.43181818c-2.02329546-1.51704546-4.90909091-.07329545-4.90909091 2.45454545"/> <path d="m0 3.23863636v2.72727273l3.12784091 3.02727273 3.69034091 2.08636368.68181818-4.59034095-.68181818-4.27329546-1.90909091-1.43181818c-2.02329546-1.51704546-4.90909091-.07329545-4.90909091 2.45454545"/>
<path d="m23.1818182 2.21590909-.6818182 4.32954546.6818182 4.53409095 3.3494318-1.65852277 3.46875-3.45511364v-2.72727273c0-2.5278409-2.8857955-3.97159091-4.9090909-2.45454545z"/> <path d="m23.1818182 2.21590909-.6818182 4.32954546.6818182 4.53409095 3.3494318-1.65852277 3.46875-3.45511364v-2.72727273c0-2.5278409-2.8857955-3.97159091-4.9090909-2.45454545z"/>
<path d="m2.04545455 22.6704545h4.77272727v-11.590909l-6.81818182-5.11363641v14.65909091c0 1.1301136.91534091 2.0454545 2.04545455 2.0454545"/> <path d="m2.04545455 22.6704545h4.77272727v-11.590909l-6.81818182-5.11363641v14.65909091c0 1.1301136.91534091 2.0454545 2.04545455 2.0454545"/>
<path d="m23.1818182 22.6704545h4.7727273c1.1301136 0 2.0454545-.9153409 2.0454545-2.0454545v-14.65909091l-6.8181818 5.11363641z"/> <path d="m23.1818182 22.6704545h4.7727273c1.1301136 0 2.0454545-.9153409 2.0454545-2.0454545v-14.65909091l-6.8181818 5.11363641z"/>
<path d="m15 8.35227273-8.18181818-6.13636364v8.86363641l8.18181818 6.1363636 8.1818182-6.1363636v-8.86363641z"/> <path d="m15 8.35227273-8.18181818-6.13636364v8.86363641l8.18181818 6.1363636 8.1818182-6.1363636v-8.86363641z"/>
<rect width="100%" height="100%" fill="transparent" /> </svg>
</a> </a>
</svg>
</footer> </footer>
@section Styles { @section Styles {
<link href="/css/variables.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
<link href="/css/home/site.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" /> <link href="/css/home/site.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
<link href="/css/home/project.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" /> <link href="/css/home/project.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
<link href="/css/home/imageLeftProject.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" /> <link href="/css/home/blog.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
<link href="/css/home/imageRightProject.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
<link href="/css/home/footer.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" /> <link href="/css/home/footer.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
} }
+8 -3
View File
@@ -1,12 +1,17 @@
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Interfaces;
using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages;
namespace JoshHeaps.Net.Pages namespace JoshHeaps.Net.Pages
{ {
public class IndexModel() : PageModel public class IndexModel(IBlogService blogService) : PageModel
{ {
public void OnGet() public List<BlogPost> LatestPosts { get; set; } = [];
{
public async Task OnGetAsync()
{
var allPosts = await blogService.GetAllPostsAsync();
LatestPosts = allPosts.Take(3).ToList();
} }
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
@{ @{
Layout = null; Layout = null;
ViewData["cssVersion"] = "1.0.2"; // <--- change this once to bust cache ViewData["cssVersion"] = "1.0.5"; // <--- change this once to bust cache
} }
<!DOCTYPE html> <!DOCTYPE html>
+7 -4
View File
@@ -1,5 +1,4 @@
using JoshHeaps.Net.Hubs; using JoshHeaps.Net.Hubs;
using JoshHeaps.Net.Services;
using JoshHeaps.Net.Services.Implementations; using JoshHeaps.Net.Services.Implementations;
using JoshHeaps.Net.Services.Interfaces; using JoshHeaps.Net.Services.Interfaces;
@@ -13,6 +12,13 @@ builder.Services.AddControllers();
builder.Services.AddSignalR(); builder.Services.AddSignalR();
builder.Services.AddHttpClient("BlogApi", client =>
{
var baseUrl = configuration["BlogApi:BaseUrl"] ?? "https://media.joshheaps.net";
client.BaseAddress = new Uri(baseUrl);
client.Timeout = TimeSpan.FromSeconds(10);
});
builder.Services.AddSingleton<IBlogService, BlogService>();
builder.Services.AddSingleton<IChessService, ChessService>(); builder.Services.AddSingleton<IChessService, ChessService>();
builder.Services.AddSingleton<IBackgroundTaskQueue, BackgroundTaskQueue>(); builder.Services.AddSingleton<IBackgroundTaskQueue, BackgroundTaskQueue>();
@@ -51,7 +57,4 @@ app.MapControllers();
app.MapHub<ChessHub>("/chessHub"); app.MapHub<ChessHub>("/chessHub");
// Build particle simulator bundle from ES6 modules
ParticleBundler.BuildBundle(app.Environment);
app.Run(); app.Run();
@@ -0,0 +1,94 @@
using System.Collections.Concurrent;
using System.Text.Json;
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Interfaces;
namespace JoshHeaps.Net.Services.Implementations;
public class BlogService : IBlogService
{
private readonly HttpClient _httpClient;
private readonly ILogger<BlogService> _logger;
private readonly TimeSpan _cacheTtl = TimeSpan.FromMinutes(5);
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new();
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
public BlogService(IHttpClientFactory httpClientFactory, ILogger<BlogService> logger)
{
_httpClient = httpClientFactory.CreateClient("BlogApi");
_logger = logger;
}
public async Task<List<BlogPost>> GetAllPostsAsync()
{
return await GetCachedAsync("all_posts",
() => FetchAsync<List<BlogPost>>("api/blog/posts")) ?? [];
}
public async Task<BlogPost?> GetPostBySlugAsync(string slug)
{
return await GetCachedAsync($"post_{slug}",
() => FetchAsync<BlogPost>($"api/blog/posts/{Uri.EscapeDataString(slug)}"));
}
public async Task<List<BlogPost>> GetPostsByTagAsync(string tag)
{
return await GetCachedAsync($"tag_{tag}",
() => FetchAsync<List<BlogPost>>($"api/blog/posts/tags/{Uri.EscapeDataString(tag)}")) ?? [];
}
private async Task<T?> FetchAsync<T>(string path) where T : class
{
try
{
var response = await _httpClient.GetAsync(path);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("Blog API returned {StatusCode} for {Path}", response.StatusCode, path);
return null;
}
var json = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<T>(json, JsonOptions);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to fetch from blog API: {Path}", path);
return null;
}
}
private async Task<T?> GetCachedAsync<T>(string key, Func<Task<T?>> factory) where T : class
{
if (_cache.TryGetValue(key, out var entry) && entry.ExpiresAt > DateTime.UtcNow)
return (T?)entry.Value;
var result = await factory();
if (result is not null)
{
_cache[key] = new CacheEntry(result, DateTime.UtcNow.Add(_cacheTtl));
}
else if (entry is not null)
{
// API unreachable — serve stale cache
_logger.LogWarning("Serving stale cache for key {Key}", key);
return (T?)entry.Value;
}
return result;
}
public void ClearCache()
{
_cache.Clear();
_logger.LogInformation("Blog cache cleared");
}
private record CacheEntry(object Value, DateTime ExpiresAt);
}
@@ -0,0 +1,11 @@
using JoshHeaps.Net.Models;
namespace JoshHeaps.Net.Services.Interfaces;
public interface IBlogService
{
Task<List<BlogPost>> GetAllPostsAsync();
Task<BlogPost?> GetPostBySlugAsync(string slug);
Task<List<BlogPost>> GetPostsByTagAsync(string tag);
void ClearCache();
}
-72
View File
@@ -1,72 +0,0 @@
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;
}
}
}
+5 -1
View File
@@ -5,5 +5,9 @@
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
}, },
"AllowedHosts": "*" "AllowedHosts": "*",
"BlogApi": {
"BaseUrl": "https://media.joshheaps.net",
"InvalidateKey": "CHANGE_ME"
}
} }
+126
View File
@@ -0,0 +1,126 @@
*, *::before, *::after {
box-sizing: border-box;
}
body {
margin: 0;
background-color: var(--color-bg);
color: var(--color-text);
overflow-x: hidden;
font-family: var(--font-body);
line-height: 1.6;
}
.blog-index {
max-width: 800px;
margin: 0 auto;
padding: 4rem clamp(1.5rem, 5vw, 3rem);
}
.blog-title {
color: var(--color-heading);
font-size: clamp(1.75rem, 3vw, 2.25rem);
font-weight: 700;
letter-spacing: -0.02em;
margin: 0 0 2.5rem;
}
.blog-filter {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
color: var(--color-text-muted);
font-size: 0.875rem;
}
.blog-filter strong {
color: var(--color-heading-secondary);
}
.blog-filter a {
color: var(--color-accent);
text-decoration: none;
transition: color 0.2s;
}
.blog-filter a:hover {
color: var(--color-accent-hover);
}
.blog-empty {
color: var(--color-text-subtle);
font-style: italic;
font-size: 1rem;
}
.blog-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.blog-card {
padding: 1.5rem 1.75rem;
border-left: 2px solid var(--color-accent-border);
background: var(--color-surface);
border-radius: 0 8px 8px 0;
transition: background 0.2s;
}
.blog-card:hover {
background: var(--color-surface-hover);
}
.blog-card-link {
text-decoration: none;
}
.blog-card-title {
color: var(--color-heading-secondary);
font-size: 1.25rem;
font-weight: 600;
margin: 0 0 0.3rem;
letter-spacing: -0.01em;
transition: color 0.2s;
}
.blog-card-link:hover .blog-card-title {
color: var(--color-accent);
}
.blog-card-date {
color: var(--color-text-subtle);
font-size: 0.75rem;
display: block;
margin-bottom: 0.5rem;
}
.blog-card-summary {
color: var(--color-text-muted);
margin: 0;
line-height: 1.6;
font-size: 0.875rem;
}
.blog-card-tags {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
margin-top: 0.75rem;
}
.blog-tag {
background: var(--color-surface-raised);
color: var(--color-text-muted);
padding: 0.2rem 0.6rem;
border-radius: 4px;
font-size: 0.7rem;
text-decoration: none;
transition: background 0.2s, color 0.2s;
}
.blog-tag:hover {
background: var(--color-surface-raised-hover);
color: var(--color-heading-secondary);
}
+184
View File
@@ -0,0 +1,184 @@
*, *::before, *::after {
box-sizing: border-box;
}
body {
margin: 0;
background-color: var(--color-bg);
color: var(--color-text);
overflow-x: hidden;
font-family: var(--font-body);
line-height: 1.6;
}
.blog-post {
max-width: 800px;
margin: 0 auto;
padding: 4rem clamp(1.5rem, 5vw, 3rem);
}
.blog-back {
color: var(--color-text-subtle);
text-decoration: none;
font-size: 0.85rem;
transition: color 0.2s;
}
.blog-back:hover {
color: var(--color-heading-secondary);
}
.blog-post-header {
margin: 2.5rem 0 2.5rem;
}
.blog-post-title {
color: var(--color-heading);
font-size: clamp(1.75rem, 3vw, 2.25rem);
font-weight: 700;
letter-spacing: -0.02em;
margin: 0 0 0.5rem;
line-height: 1.25;
}
.blog-post-date {
color: var(--color-text-subtle);
font-size: 0.8rem;
display: block;
}
.blog-post-tags {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
margin-top: 0.75rem;
}
.blog-tag {
background: var(--color-surface-raised);
color: var(--color-text-muted);
padding: 0.2rem 0.6rem;
border-radius: 4px;
font-size: 0.7rem;
text-decoration: none;
transition: background 0.2s, color 0.2s;
}
.blog-tag:hover {
background: var(--color-surface-raised-hover);
color: var(--color-heading-secondary);
}
/* ── Post Content ── */
.post-content {
color: var(--color-text);
line-height: 1.8;
font-size: 1rem;
}
.post-content h1,
.post-content h2,
.post-content h3,
.post-content h4 {
color: var(--color-heading-secondary);
margin-top: 2rem;
margin-bottom: 0.75rem;
letter-spacing: -0.01em;
}
.post-content h1 { font-size: 1.6rem; }
.post-content h2 { font-size: 1.35rem; }
.post-content h3 { font-size: 1.15rem; }
.post-content p {
margin: 0 0 1.25rem;
}
.post-content a {
color: var(--color-accent);
text-decoration: none;
transition: color 0.2s;
}
.post-content a:hover {
color: var(--color-accent-hover);
}
.post-content ul,
.post-content ol {
margin: 0 0 1.25rem 1.5rem;
}
.post-content li {
margin-bottom: 0.4rem;
}
.post-content code {
background: var(--color-surface-raised);
color: var(--color-heading-secondary);
padding: 0.15rem 0.4rem;
border-radius: 4px;
font-size: 0.85em;
font-family: var(--font-mono);
}
.post-content pre {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 1.25rem 1.5rem;
overflow-x: auto;
margin: 0 0 1.25rem;
scrollbar-width: thin;
scrollbar-color: var(--color-border) transparent;
}
.post-content pre::-webkit-scrollbar {
height: 6px;
}
.post-content pre::-webkit-scrollbar-track {
background: transparent;
}
.post-content pre::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 3px;
}
.post-content pre[class*="language-"] {
background: var(--color-surface);
}
.post-content pre code {
background: none;
color: var(--prism-text);
padding: 0;
font-size: 0.85rem;
}
.post-content blockquote {
border-left: 2px solid var(--color-accent-border);
margin: 0 0 1.25rem;
padding: 0.75rem 1.25rem;
color: var(--color-text-muted);
background: var(--color-surface);
border-radius: 0 8px 8px 0;
}
.post-content img {
max-width: 100%;
border-radius: 8px;
margin: 1.25rem 0;
}
.post-content strong {
color: var(--color-heading-secondary);
}
.post-content hr {
border: none;
border-top: 1px solid var(--color-border);
margin: 2rem 0;
}
@@ -0,0 +1,154 @@
/* Prism.js Tomorrow Night theme (customized) */
/* Original: https://github.com/PrismJS/prism/blob/master/themes/prism-tomorrow.css */
/* ============================================
Theme colors — edit these to restyle
============================================ */
:root {
--prism-background: #0a0a0a;
--prism-text: #909090;
--prism-comment: #555;
--prism-punctuation: #707070;
--prism-tag: #09ff00;
--prism-function: #fff;
--prism-boolean: #1ab815;
--prism-number: #1ab815;
--prism-property: #c8c8c8;
--prism-class: #c8c8c8;
--prism-keyword: #09ff00;
--prism-string: #43c760;
--prism-variable: #43c760;
--prism-operator: #999;
--prism-inserted: #09ff00;
}
code[class*="language-"],
pre[class*="language-"] {
color: var(--prism-text);
background: none;
font-family: var(--font-mono);
font-size: 1em;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
tab-size: 4;
hyphens: none;
}
pre[class*="language-"] {
padding: 1em;
margin: 0.5em 0;
overflow: auto;
}
:not(pre) > code[class*="language-"],
pre[class*="language-"] {
background: var(--prism-background);
}
:not(pre) > code[class*="language-"] {
padding: 0.1em;
border-radius: 0.3em;
white-space: normal;
}
/* Comments */
.token.comment,
.token.block-comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: var(--prism-comment);
}
/* Punctuation */
.token.punctuation {
color: var(--prism-punctuation);
}
/* Tags, attributes, deleted */
.token.tag,
.token.attr-name,
.token.namespace,
.token.deleted {
color: var(--prism-tag);
}
/* Function names */
.token.function-name,
.token.function {
color: var(--prism-function);
}
/* Booleans */
.token.boolean {
color: var(--prism-boolean);
}
/* Numbers */
.token.number {
color: var(--prism-number);
}
/* Properties */
.token.property {
color: var(--prism-property);
}
/* Classes, constants, symbols */
.token.class-name,
.token.constant,
.token.symbol {
color: var(--prism-class);
}
/* Keywords, selectors, builtins */
.token.selector,
.token.important,
.token.atrule,
.token.keyword,
.token.builtin {
color: var(--prism-keyword);
}
/* Strings, chars, attr values, regex */
.token.string,
.token.char,
.token.attr-value,
.token.regex {
color: var(--prism-string);
}
/* Variables */
.token.variable {
color: var(--prism-variable);
}
/* Operators, entities, URLs */
.token.operator,
.token.entity,
.token.url {
color: var(--prism-operator);
}
/* Inserted */
.token.inserted {
color: var(--prism-inserted);
}
/* Formatting */
.token.bold,
.token.important {
font-weight: 700;
}
.token.italic {
font-style: italic;
}
.token.entity {
cursor: help;
}
+65
View File
@@ -0,0 +1,65 @@
/* ── Blog Section ── */
#BlogBox {
max-width: 1100px;
margin: clamp(4rem, 8vw, 8rem) auto;
padding: 0 clamp(1.5rem, 5vw, 4rem);
}
#blogPostsList {
display: flex;
flex-direction: column;
gap: 1rem;
}
.blog-teaser-item {
display: flex;
flex-direction: column;
gap: 0.3rem;
padding: 1.25rem 1.5rem;
border-left: 2px solid var(--color-accent-border);
background: var(--color-surface);
border-radius: 0 8px 8px 0;
transition: background 0.2s;
}
.blog-teaser-item:hover {
background: var(--color-surface-hover);
}
.blog-teaser-title {
font-size: clamp(1rem, 1.5vw, 1.15rem);
font-weight: 600;
color: var(--color-heading-secondary);
text-decoration: none;
transition: color 0.2s;
}
.blog-teaser-title:hover {
color: var(--color-accent);
}
.blog-teaser-date {
font-size: 0.75rem;
color: var(--color-text-subtle);
}
.blog-teaser-summary {
font-size: 0.85rem;
color: var(--color-text-muted);
margin: 0.25rem 0 0;
line-height: 1.6;
}
.blog-view-all {
display: inline-block;
margin-top: 1.5rem;
font-size: 0.85rem;
color: var(--color-accent);
text-decoration: none;
transition: color 0.2s;
}
.blog-view-all:hover {
color: var(--color-accent-hover);
}
+18 -10
View File
@@ -1,17 +1,25 @@
#Contacts { /* ── Footer / Contacts ── */
margin-top: 20vw;
#Contacts {
display: flex;
justify-content: center;
gap: 1.5rem;
padding: clamp(4rem, 8vw, 6rem) 0 clamp(3rem, 5vw, 4rem);
}
.social-link {
display: flex;
align-items: center;
text-decoration: none;
} }
.social-icon { .social-icon {
height: auto; height: auto;
width: 3.5vw; width: clamp(1.25rem, 2.5vw, 1.75rem);
margin-bottom: 5vw; fill: var(--color-text-subtle);
margin-left: 3vw; transition: fill 0.2s;
fill: rgb(212, 212, 212);
transition: fill 0.5s;
} }
.social-icon:hover { .social-link:hover .social-icon {
fill: white; fill: var(--color-heading-secondary);
transition: fill 0.5s;
} }
@@ -1,36 +0,0 @@
.diagonal-section-left {
position: absolute;
clip-path: polygon(0 0, 85vw 0, 60vw 100%, 0 100%);
background-color: rgb(104, 255, 0, 0.39);
transition: clip-path 0.5s ease;
}
.diagonal-section-left:has(.projectImage:hover) {
clip-path: polygon(0 0, 87vw 0, 58vw 100%, 0 100%);
transition: clip-path 0.5s ease;
}
.projectImageLeft {
width: 33vw;
height: auto;
margin: 3vw;
}
.projectTextRight {
position: relative;
z-index: 1;
margin-left: 50vw;
}
.projectHeaderRight {
margin: 0;
color: rgb(212, 212, 212);
font-size: 4vw;
}
.projectDescriptionRight {
margin: 0;
color: rgb(212, 212, 212);
font-size: 2vw;
width: 40vw;
}
@@ -1,38 +0,0 @@
.diagonal-section-right {
position: absolute;
clip-path: polygon(40vw 0, 100% 0, 100% 100%, 15vw 100%);
background-color: rgb(104, 255, 0, 0.39);
transition: clip-path 0.5s ease;
}
.diagonal-section-right:has(.projectImage:hover) {
clip-path: polygon(42vw 0, 100% 0, 100% 100%, 13vw 100%);
transition: clip-path 0.5s ease;
}
.projectImageRight {
width: 33vw;
height: auto;
margin: 3vw;
float: right;
}
.projectTextLeft {
position: relative;
z-index: 1;
}
.projectHeaderLeft {
margin: 0;
color: rgb(212, 212, 212);
font-size: 4vw;
margin-left: 10vw;
}
.projectDescriptionLeft {
margin: 0;
color: rgb(212, 212, 212);
font-size: 2vw;
width: 40vw;
margin-left: 10vw;
}
+77 -16
View File
@@ -1,29 +1,90 @@
#ProjectsBox { /* ── Projects Section ── */
#ProjectsBox {
max-width: 1100px;
margin: 0 auto;
padding: 0 clamp(1.5rem, 5vw, 4rem);
}
.projects-grid {
display: grid; display: grid;
grid-auto-rows: 1fr; grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr));
gap: 20vw; gap: 1.5rem;
} }
.projectContainer { /* ── Card ── */
position: relative;
display: flex; .project-card {
align-items: center; border: 1px solid var(--color-border);
border-radius: 12px;
overflow: hidden;
background: var(--color-surface);
transition: border-color 0.25s, transform 0.25s;
} }
.displayBox { .project-card:hover {
width: 100vw; border-color: var(--color-border-hover);
height: auto; transform: translateY(-2px);
} }
a.projectContainer { /* ── Image ── */
text-decoration: none;
.project-image-link {
display: block;
overflow: hidden;
} }
.projectImage { .projectImage {
transition: width 0.5s ease; width: 100%;
height: auto;
display: block;
transition: transform 0.4s ease;
} }
.projectImage:hover { .project-card:hover .projectImage {
width: 40vw; transform: scale(1.03);
transition: width 0.5s ease; }
/* ── Info ── */
.project-info {
padding: 1.25rem 1.5rem 1.5rem;
}
.project-title {
margin: 0 0 0.5rem;
font-size: 1.1rem;
font-weight: 600;
color: var(--color-heading);
letter-spacing: -0.01em;
}
.project-description {
margin: 0 0 1rem;
font-size: 0.85rem;
line-height: 1.65;
color: var(--color-text-muted);
}
.project-link {
font-size: 0.8rem;
color: var(--color-accent);
text-decoration: none;
transition: color 0.2s;
}
.project-link:hover {
color: var(--color-accent-hover);
}
/* ── Mobile ── */
@media (max-width: 600px) {
.projects-grid {
grid-template-columns: 1fr;
}
.project-info {
padding: 1rem 1.25rem 1.25rem;
}
} }
+108 -41
View File
@@ -1,67 +1,134 @@
body { *, *::before, *::after {
margin: 0px; box-sizing: border-box;
background-color: #2b2c30;
color: #09ff00;
overflow-x: hidden;
cursor: default;
} }
body {
margin: 0;
background-color: var(--color-bg);
color: var(--color-text);
overflow-x: hidden;
font-family: var(--font-body);
line-height: 1.6;
}
/* ── Navigation ── */
#Header { #Header {
margin-top: 2vw; display: flex;
justify-content: space-between;
align-items: center;
max-width: 1100px;
margin: 0 auto;
padding: 2rem clamp(1.5rem, 5vw, 4rem);
} }
.headerOption { .site-name {
font: bold 2vw arial; font-size: 1.05rem;
display: inline; font-weight: 700;
margin-left: 10vw; color: var(--color-heading);
letter-spacing: -0.01em;
}
.nav-links {
display: flex;
gap: clamp(1.25rem, 3vw, 2.5rem);
}
.nav-link {
font-size: 0.875rem;
color: var(--color-text-subtle);
cursor: pointer; cursor: pointer;
transition: color 0.5s ease, font 0.5s ease; transition: color 0.2s;
text-decoration: none;
} }
.headerOption:hover { .nav-link:hover {
font: bold 2.3vw arial; color: var(--color-heading-secondary);
transition: color 0.5s ease, font 0.5s ease; }
/* ── Hero / Welcome Message ── */
#Hero {
max-width: 1100px;
margin: 0 auto;
padding: clamp(4rem, 10vw, 8rem) clamp(1.5rem, 5vw, 4rem) clamp(4rem, 8vw, 6rem);
} }
#WelcomeMessage { #WelcomeMessage {
font: 3vw arial; font-size: clamp(1.25rem, 2.2vw, 1.75rem);
width: auto; max-width: 720px;
height: 40vw; min-height: clamp(12rem, 28vw, 20rem);
margin-top: 15vw; line-height: 1.7;
text-align: left; color: var(--color-accent);
padding-left: 10vw; font-family: var(--font-mono);
padding-right: 10vw;
} }
/* ── Section Titles ── */
.section-title {
font-size: clamp(1.3rem, 2.2vw, 1.75rem);
font-weight: 700;
color: var(--color-heading);
margin: 0 0 clamp(1.5rem, 3vw, 2.5rem);
letter-spacing: -0.02em;
}
/* ── Demos ── */
#DemosBox { #DemosBox {
margin: 10vw; max-width: 1100px;
display: flex; margin: clamp(4rem, 8vw, 8rem) auto;
flex-direction: column; padding: 0 clamp(1.5rem, 5vw, 4rem);
justify-content: end;
width: 80%;
} }
#DemoHeader { #buttonWrapper {
margin: 0; display: grid;
color: rgb(212, 212, 212); grid-template-columns: repeat(auto-fill, minmax(min(200px, 100%), 1fr));
font-size: 4vw; gap: 0.75rem;
} }
.demoButton { .demoButton {
margin-top: 5vw; padding: 0.875rem 1.25rem;
margin-right: 2vw;
padding-top: 0.5vw;
padding-bottom: 0.5vw;
text-align: center; text-align: center;
border: none; border: 1px solid var(--color-border);
border-radius: 5vw; border-radius: 8px;
width: 20vw; font-size: 0.875rem;
height: 8vw; font-family: inherit;
font-size: 2vw; background: var(--color-surface);
background-color: rgb(104, 255, 0, 0.39); color: var(--color-text);
cursor: pointer; cursor: pointer;
transition: background 0.2s, border-color 0.2s, color 0.2s;
}
.demoButton:hover {
background: var(--color-surface-hover);
border-color: var(--color-border-hover);
color: var(--color-heading-secondary);
} }
#MoreFiller { #MoreFiller {
cursor: default; cursor: default;
opacity: 0.3;
}
#MoreFiller:hover {
background: var(--color-surface);
border-color: var(--color-border);
color: var(--color-text);
}
/* ── Mobile ── */
@media (max-width: 600px) {
#Header {
padding: 1.25rem 1.25rem;
}
.site-name {
font-size: 0.95rem;
}
.nav-link {
font-size: 0.8rem;
}
} }
+218 -194
View File
@@ -1,111 +1,56 @@
/* ======================================== /* ------- 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 { html, body {
height: 100%; height: 100%;
margin: 0; margin: 0;
} }
body { body {
background: var(--bg-primary); background: #000;
color: var(--text-primary); color: #e6e6e6;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif; font-family: ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,Arial,sans-serif;
overflow: hidden; overflow: hidden;
} }
/* ======================================== /* canvas as full-screen background */
CANVAS
======================================== */
.sim { .sim {
position: fixed; position: fixed;
inset: 0; inset: 0; /* top:0 right:0 bottom:0 left:0 */
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
display: block; display: block;
z-index: var(--z-canvas); z-index: 0;
} }
.container { /* floating toolbar */
position: relative;
height: 100vh;
}
/* ========================================
TOOLBAR
======================================== */
.toolbar { .toolbar {
position: fixed; position: fixed;
top: var(--panel-gap); top: 12px;
left: var(--panel-gap); left: 12px;
right: var(--panel-gap); right: 12px;
z-index: var(--z-ui); z-index: 20;
display: flex; display: flex;
gap: var(--spacing-xl); gap: 1rem;
align-items: center; align-items: center;
padding: var(--spacing-md) var(--spacing-lg); padding: .6rem .75rem;
background: var(--bg-panel); background: rgba(15,15,15,.65);
border: 1px solid var(--border-color); border: 1px solid #2a2a2a;
border-radius: var(--border-radius); border-radius: 10px;
backdrop-filter: blur(6px); backdrop-filter: blur(6px);
} }
/* collapse/expand toolbar */
.toolbar .spacer { .toolbar .spacer {
flex: 1 1 auto; flex: 1 1 auto;
} }
#toggleToolbar { #toggleToolbar {
padding: var(--spacing-xs) var(--spacing-sm); padding: .35rem .5rem;
border-radius: var(--border-radius-sm); border-radius: .4rem;
} }
/* Collapsed toolbar state (desktop) */
.toolbar.collapsed { .toolbar.collapsed {
left: var(--panel-gap); padding: .35rem .5rem;
right: auto; gap: .5rem;
width: var(--fab-size);
height: var(--fab-size);
padding: 0;
gap: 0;
display: grid;
place-items: center;
} }
.toolbar.collapsed .group, .toolbar.collapsed .group,
@@ -115,150 +60,93 @@ body {
display: none; display: none;
} }
.toolbar.collapsed #toggleToolbar { /* floating rules panel (details) */
width: 100%;
height: 100%;
padding: 0;
border-radius: var(--border-radius-fab);
font-size: 0;
}
.toolbar.collapsed #toggleToolbar::before {
content: "☰";
font-size: 18px;
}
/* ========================================
RULE EDITOR PANEL
======================================== */
.rulepanel { .rulepanel {
position: fixed; position: fixed;
left: var(--panel-gap); left: 12px;
right: var(--panel-gap); right: 12px;
bottom: var(--panel-gap); bottom: 12px;
z-index: var(--z-ui); z-index: 20;
background: var(--bg-panel-dark); background: rgba(11,11,11,.65);
border: 1px solid var(--border-color-dark); border: 1px solid #1a1a1a;
border-radius: var(--border-radius); border-radius: 10px;
backdrop-filter: blur(6px); backdrop-filter: blur(6px);
max-height: 42vh; overflow: hidden; /* collapsed state shows only summary */
overflow: auto; max-height: 42vh; /* when open, content scrolls inside */
} }
.rulepanel[open] { .rulepanel[open] {
max-height: calc(100dvh - 24px - 60px);
overflow: auto; overflow: auto;
scrollbar-gutter: stable both-edges;
overscroll-behavior: contain;
scrollbar-width: none; /* Firefox */
}
.rulepanel[open]::-webkit-scrollbar {
width: 0;
height: 0;
} }
.rulepanel > summary { .rulepanel > summary {
position: sticky;
top: 0;
padding: var(--spacing-md) var(--spacing-lg);
margin: 0;
cursor: pointer; cursor: pointer;
font-weight: 600; font-weight: 600;
list-style: none; list-style: none;
user-select: none; user-select: none;
border-bottom: 1px solid var(--border-color-dark); padding: .6rem .75rem;
margin: 0;
position: sticky;
top: 0;
background: transparent;
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--spacing-sm); gap: .5rem;
border-bottom: 1px solid #1a1a1a;
} }
.rulepanel > summary::-webkit-details-marker { .rulepanel > summary::-webkit-details-marker {
display: none; display: none;
} }
.rulepanel[open] > summary::after { .rulepanel > summary::after {
content: "▾"; content: "▾";
margin-left: auto; margin-left: auto;
opacity: 0.8; opacity: .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 { .rulepanel:not([open]) > summary::after {
content: ""; content: "";
}
.rulepanel:not([open]) > summary::before {
content: "Rules";
font-size: 18px;
} }
.rules-body { .rules-body {
padding: var(--spacing-lg) var(--spacing-xl) var(--spacing-xl); padding: .75rem 1rem 1rem;
display: grid; display: grid;
gap: var(--spacing-lg); gap: .75rem;
} }
.rule-grid { .rule-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(200px,1fr));
gap: var(--spacing-sm) var(--spacing-xl); gap: .5rem 1rem;
} }
.rule-item { .rule-item {
align-items: center; align-items: center;
gap: var(--spacing-sm); gap: .5rem;
} }
.rule-actions { /* buttons + inputs (keep your existing styles, just a few tweaks) */
display: flex;
gap: var(--spacing-sm);
}
.rule-import {
display: grid;
gap: var(--spacing-sm);
}
/* ========================================
FORM CONTROLS
======================================== */
button { button {
background: var(--bg-button); background: #1f1f1f;
border: 1px solid var(--border-color); border: 1px solid #2a2a2a;
color: var(--text-primary); color: #e6e6e6;
padding: 0.4rem 0.7rem; padding: .4rem .7rem;
border-radius: var(--spacing-sm); border-radius: .5rem;
cursor: pointer; cursor: pointer;
} }
button:hover { button:hover {
background: var(--bg-button-hover); background: #2a2a2a;
}
/* range styling (from your sheet; leave as-is) */
:root {
--slider-track: #444;
--slider-fill: #4cafef;
--slider-thumb: #ff5722;
} }
/* Range slider styling */
input[type="range"] { input[type="range"] {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
@@ -305,51 +193,187 @@ input[type="range"]::-moz-range-progress {
border-radius: 3px; border-radius: 3px;
} }
/* ======================================== /* container no longer controls layout; keep for semantics */
MOBILE STYLES .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 */
@media (max-width: 640px) { @media (max-width: 640px) {
/* Toolbar defaults to FAB on mobile */ /* Default to FAB */
.toolbar { .toolbar {
top: var(--panel-gap); top: 12px;
left: var(--panel-gap); left: 12px;
width: var(--fab-size); width: 44px;
height: var(--fab-size); height: 44px;
padding: 0; padding: 0;
gap: 0; gap: 0;
display: grid; display: grid;
place-items: center; place-items: center;
} }
.toolbar .group, .toolbar .group, .toolbar #restart, .toolbar #permalink, .toolbar .spacer {
.toolbar #restart,
.toolbar #permalink,
.toolbar .spacer {
display: none; display: none;
} }
/* FAB button icon */
#toggleToolbar { #toggleToolbar {
width: 100%; width: 100%;
height: var(--fab-size); height: 44px;
padding: 0; padding: 0;
border-radius: var(--border-radius-fab); border-radius: 8px;
font-size: 18px; font-size: 18px;
} }
#toggleToolbar::before { #toggleToolbar::before {
width: var(--fab-size); width: 44px;
font-size: 18px; font-size: 18px;
} }
/* Expanded drawer state on mobile */ /* Expanded drawer */
.toolbar.mobile.open { .toolbar.mobile.open {
left: var(--panel-gap); left: 12px;
width: min(85vw, 420px); width: min(85vw, 420px);
height: 70vh; height: 70vh; /* scroll within drawer if needed */
padding: var(--spacing-md) var(--spacing-lg); padding: .6rem .75rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.65rem; gap: .65rem;
overflow: auto; overflow: auto;
} }
@@ -361,7 +385,7 @@ input[type="range"]::-moz-range-progress {
.toolbar.mobile.open .group { .toolbar.mobile.open .group {
flex-wrap: wrap; flex-wrap: wrap;
gap: var(--spacing-sm); gap: .5rem;
} }
.toolbar.mobile.open input[type="number"], .toolbar.mobile.open input[type="number"],
+28
View File
@@ -0,0 +1,28 @@
:root {
/* Backgrounds */
--color-bg: #0a0a0a;
--color-surface: rgba(255, 255, 255, 0.02);
--color-surface-hover: rgba(255, 255, 255, 0.04);
--color-surface-raised: rgba(255, 255, 255, 0.05);
--color-surface-raised-hover: rgba(255, 255, 255, 0.08);
/* Text */
--color-heading: #fff;
--color-heading-secondary: #e0e0e0;
--color-text: #b0b0b0;
--color-text-muted: #aaa;
--color-text-subtle: #777;
/* Accent */
--color-accent: #09ff00;
--color-accent-hover: #5fff5f;
/* Borders */
--color-border: rgba(255, 255, 255, 0.07);
--color-border-hover: rgba(255, 255, 255, 0.15);
--color-accent-border: rgba(9, 255, 0, 0.4);
/* Typography */
--font-body: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
}
@@ -1,138 +0,0 @@
# 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.
@@ -1,97 +0,0 @@
#!/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
@@ -1,35 +0,0 @@
/**
* 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);
}
@@ -1,17 +0,0 @@
/**
* 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"];
@@ -1,41 +0,0 @@
/**
* 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);
@@ -1,233 +0,0 @@
/**
* 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();
}
@@ -1,102 +0,0 @@
/**
* 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;
}
@@ -1,197 +0,0 @@
/**
* 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
);
}
});
}
}
@@ -1,19 +0,0 @@
/**
* 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;
};
}
@@ -1,34 +0,0 @@
/**
* 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();
}
}
@@ -1,71 +0,0 @@
/**
* 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 };
}
@@ -1,74 +0,0 @@
/**
* 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
};
}
@@ -1,75 +0,0 @@
/**
* 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);
}
});
}
@@ -1,33 +0,0 @@
/**
* 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
}
});
}
@@ -1,47 +0,0 @@
/**
* 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;
}
+9 -2
View File
@@ -45,9 +45,9 @@ function simulateTyping(element, text, speed = 50) {
function showClickedContents(option) { function showClickedContents(option) {
if (option === 'projects') { if (option === 'projects') {
document.querySelector("#ChessProject > div.diagonal-section-left").scrollIntoView({ document.querySelector("#ProjectsBox").scrollIntoView({
behavior: "smooth", behavior: "smooth",
block: "center", block: "start",
inline: "nearest" inline: "nearest"
}); });
} }
@@ -68,4 +68,11 @@ function showClickedContents(option) {
inline: "nearest" inline: "nearest"
}); });
} }
else if (option === 'blog') {
document.querySelector("#BlogBox").scrollIntoView({
behavior: "smooth",
block: "start",
inline: "nearest"
});
}
} }