Author SHA1 Message Date
Josh-Heaps 9c4ff64070 Fix jitter 2026-08-20 09:54:18 -06:00
Josh-Heaps 639ace0030 Add transistors 2026-08-18 12:18:54 -06:00
Josh-Heaps cb0df5728c Add basic breadboard simulator 2026-08-17 14:28:52 -06:00
jheapsandClaude Sonnet 4.6 0393dacf63 Embed user roles as JWT claims in SSO token
IssueToken is now async so it can query app.user_roles before signing
the JWT. Role names are added as ClaimTypes.Role claims, enabling
role-based authorization in downstream apps (e.g. retirementAdmin).

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-18 19:29:54 -06:00
Josh Heaps 7bbd679171 Merge pull request #15 from JoshHeaps/feature/blog
Add sso for other websites
2026-04-21 16:15:45 -06:00
Josh-Heaps e3d9c8f273 Add sso for other websites 2026-04-21 16:13:26 -06:00
Josh Heaps 64107d2e52 Merge pull request #14 from JoshHeaps/feature/blog
add cache invalidation
2026-03-06 17:06:51 -07:00
Josh-Heaps 15a84b2027 add cache invalidation 2026-03-06 17:05:36 -07:00
Josh Heaps f1e2aad8bf Merge pull request #13 from JoshHeaps/feature/blog
Add image upload capabilities
2026-03-06 16:35:39 -07:00
Josh-Heaps cb86e15cc0 Add image upload capabilities 2026-03-06 16:31:20 -07:00
Josh Heaps 9b182eaedf Merge pull request #12 from JoshHeaps/feature/blog
Add blog management: migration, API, service, and admin UI
2026-03-06 16:13:30 -07:00
Josh-Heaps 9f3f610025 Update blog button 2026-03-06 16:12:58 -07:00
Josh-HeapsandClaude Opus 4.6 a3c90ad06e Add blog management: migration, API, service, and admin UI
Blog posts stored in PostgreSQL with markdown rendering via Markdig.
Public API for reads, admin-only writes with dual auth (JWT + session).
Admin page for creating, editing, and deleting posts.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-06 15:33:49 -07:00
Josh Heaps 7dc70767d2 Merge pull request #11 from JoshHeaps/features/MultiFeatureBranch
Prescription ai update
2026-02-17 09:35:56 -07:00
Josh-Heaps bb35460097 Prescription ai update 2026-02-17 09:32:43 -07:00
Josh Heaps dea8d1bf90 Merge pull request #10 from JoshHeaps/features/MultiFeatureBranch
bugfix
2026-02-17 09:13:09 -07:00
Josh-Heaps d0c8c1249b bugfix 2026-02-17 09:11:49 -07:00
Josh Heaps 7e6ce61f03 Merge pull request #9 from JoshHeaps/features/MultiFeatureBranch
Features/multi feature branch
2026-02-13 14:15:12 -07:00
Josh-Heaps e971a5f329 Add custom colors 2026-02-13 14:13:32 -07:00
Josh-Heaps 91ef4f41dd Make an all patients view. 2026-02-13 10:11:44 -07:00
Josh Heaps e3d94b9f43 Merge pull request #8 from JoshHeaps/features/MultiFeatureBranch
Features/multi feature branch
2026-02-12 15:18:10 -07:00
Josh-Heaps cfd6bb530b Fix so people are scoped to users 2026-02-12 15:14:49 -07:00
Josh-Heaps c173677f57 Add password reset functionality 2026-02-12 13:14:01 -07:00
108 changed files with 15256 additions and 330 deletions
+3 -1
View File
@@ -11,7 +11,9 @@
"Bash(tree:*)", "Bash(tree:*)",
"Bash(del Index.cshtml Index.cshtml.cs)", "Bash(del Index.cshtml Index.cshtml.cs)",
"Bash(dotnet build:*)", "Bash(dotnet build:*)",
"Bash(find:*)" "Bash(find:*)",
"Bash(grep:*)",
"Bash(ls:*)"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []
+206
View File
@@ -0,0 +1,206 @@
using Media.JoshHeaps.Net.Services;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
namespace Media.JoshHeaps.Net.Api;
[ApiController]
[Route("api/blog")]
public class BlogApi(BlogService blogService, DbExecutor dbExecutor, IHttpClientFactory httpClientFactory, IConfiguration configuration, ILogger<BlogApi> logger) : ControllerBase
{
[HttpGet("posts")]
public async Task<IActionResult> GetPosts()
{
var posts = await blogService.GetAllPostsAsync();
return Ok(posts.Select(MapToPublicDto));
}
[HttpGet("posts/{slug}")]
public async Task<IActionResult> GetPostBySlug(string slug)
{
var post = await blogService.GetPostBySlugAsync(slug);
if (post is null) return NotFound();
return Ok(MapToPublicDto(post));
}
[HttpGet("posts/tags/{tag}")]
public async Task<IActionResult> GetPostsByTag(string tag)
{
var posts = await blogService.GetPostsByTagAsync(tag);
return Ok(posts.Select(MapToPublicDto));
}
[HttpGet("posts/admin/{id}")]
public async Task<IActionResult> GetPostForAdmin(long id)
{
var userId = GetUserIdFromAuth();
if (userId is null) return Unauthorized();
if (!await IsAdmin(userId.Value)) return Forbid();
var post = await blogService.GetPostByIdAsync(id);
if (post is null) return NotFound();
return Ok(MapToAdminDto(post));
}
[HttpPost("posts")]
public async Task<IActionResult> CreatePost([FromBody] CreateBlogPostRequest request)
{
var userId = GetUserIdFromAuth();
if (userId is null) return Unauthorized();
if (!await IsAdmin(userId.Value)) return Forbid();
if (string.IsNullOrWhiteSpace(request.Title))
return BadRequest(new { error = "Title is required" });
if (string.IsNullOrWhiteSpace(request.MarkdownContent))
return BadRequest(new { error = "Content is required" });
var post = await blogService.CreatePostAsync(
request.Title.Trim(),
request.Summary?.Trim() ?? "",
request.MarkdownContent,
request.Tags ?? [],
userId.Value,
request.PublishedDate ?? DateTime.UtcNow);
if (post is null)
return StatusCode(500, new { error = "Failed to create post" });
_ = InvalidateFrontendCacheAsync();
return Ok(MapToAdminDto(post));
}
[HttpPut("posts/{id}")]
public async Task<IActionResult> UpdatePost(long id, [FromBody] UpdateBlogPostRequest request)
{
var userId = GetUserIdFromAuth();
if (userId is null) return Unauthorized();
if (!await IsAdmin(userId.Value)) return Forbid();
if (string.IsNullOrWhiteSpace(request.Title))
return BadRequest(new { error = "Title is required" });
if (string.IsNullOrWhiteSpace(request.MarkdownContent))
return BadRequest(new { error = "Content is required" });
var post = await blogService.UpdatePostAsync(
id,
request.Title.Trim(),
request.Summary?.Trim() ?? "",
request.MarkdownContent,
request.Tags ?? [],
request.PublishedDate ?? DateTime.UtcNow);
if (post is null)
return NotFound(new { error = "Post not found" });
_ = InvalidateFrontendCacheAsync();
return Ok(MapToAdminDto(post));
}
[HttpDelete("posts/{id}")]
public async Task<IActionResult> DeletePost(long id)
{
var userId = GetUserIdFromAuth();
if (userId is null) return Unauthorized();
if (!await IsAdmin(userId.Value)) return Forbid();
var deleted = await blogService.DeletePostAsync(id);
if (!deleted) return NotFound(new { error = "Post not found" });
_ = InvalidateFrontendCacheAsync();
return Ok(new { success = true });
}
[HttpPost("images")]
public async Task<IActionResult> UploadImage(IFormFile file)
{
var userId = GetUserIdFromAuth();
if (userId is null) return Unauthorized();
if (!await IsAdmin(userId.Value)) return Forbid();
if (file is null || file.Length == 0)
return BadRequest(new { error = "No file provided" });
var (url, error) = await blogService.SaveImageAsync(file);
if (url is null)
return BadRequest(new { error });
return Ok(new { url });
}
[HttpGet("images/{fileName}")]
public IActionResult GetImage(string fileName)
{
var (filePath, mimeType) = blogService.GetImagePath(fileName);
if (filePath is null)
return NotFound();
return PhysicalFile(filePath, mimeType!);
}
private static object MapToPublicDto(Models.BlogPost post) => new
{
post.Id,
post.Slug,
post.Title,
post.Summary,
post.Tags,
post.PublishedDate,
post.HtmlContent
};
private static object MapToAdminDto(Models.BlogPost post) => new
{
post.Id,
post.Slug,
post.Title,
post.Summary,
post.MarkdownContent,
post.Tags,
post.PublishedDate,
post.CreatedAt,
post.UpdatedAt
};
private async Task InvalidateFrontendCacheAsync()
{
try
{
var url = configuration["BlogCache:InvalidateUrl"];
var key = configuration["BlogCache:InvalidateKey"];
if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(key)) return;
var client = httpClientFactory.CreateClient();
client.Timeout = TimeSpan.FromSeconds(5);
var request = new HttpRequestMessage(HttpMethod.Post, url);
request.Headers.Add("X-Invalidate-Key", key);
await client.SendAsync(request);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to invalidate frontend blog cache");
}
}
private async Task<bool> IsAdmin(long userId)
{
return await dbExecutor.ExecuteAsync<bool>(
"SELECT EXISTS(SELECT 1 FROM app.user_roles ur JOIN app.roles r ON ur.role_id = r.id WHERE ur.user_id = @UserId AND r.name = 'admin')",
new { UserId = userId });
}
private long? GetUserIdFromAuth()
{
var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!string.IsNullOrEmpty(userIdClaim) && long.TryParse(userIdClaim, out var jwtUserId))
return jwtUserId;
var userIdString = HttpContext.Session.GetString("UserId");
if (!string.IsNullOrEmpty(userIdString) && long.TryParse(userIdString, out var sessionUserId))
return sessionUserId;
return null;
}
}
public record CreateBlogPostRequest(string Title, string? Summary, string MarkdownContent, List<string>? Tags, DateTime? PublishedDate);
public record UpdateBlogPostRequest(string Title, string? Summary, string MarkdownContent, List<string>? Tags, DateTime? PublishedDate);
+141
View File
@@ -0,0 +1,141 @@
using System.Security.Claims;
using System.Text.Json;
using Media.JoshHeaps.Net.Models;
using Media.JoshHeaps.Net.Services;
using Microsoft.AspNetCore.Mvc;
namespace Media.JoshHeaps.Net.Api;
[ApiController]
[Route("api/breadboard")]
public class BreadboardApi(BreadboardService breadboardService) : ControllerBase
{
/// <summary>
/// Pipeline-level guard so an oversized body is rejected before it is buffered into a
/// string. The validator's 2 MB circuit cap is the real limit; the extra megabyte is
/// headroom for the JSON envelope around it.
/// </summary>
private const long MaxRequestBodyBytes = 3L * 1024 * 1024;
[HttpGet("projects")]
public async Task<IActionResult> ListProjects()
{
var userId = GetUserIdFromAuth();
if (userId == null)
{
return Unauthorized(Problems("Not authenticated"));
}
var projects = await breadboardService.GetProjectsAsync(userId.Value);
return Ok(projects);
}
[HttpPost("projects")]
[RequestSizeLimit(MaxRequestBodyBytes)]
public async Task<IActionResult> CreateProject([FromBody] CreateBreadboardProjectRequest request)
{
var userId = GetUserIdFromAuth();
if (userId == null)
{
return Unauthorized(Problems("Not authenticated"));
}
var result = await breadboardService.CreateProjectAsync(userId.Value, request.Name, request.Description);
return MapResult(result);
}
[HttpGet("projects/{projectId:long}")]
public async Task<IActionResult> GetProject(long projectId)
{
var userId = GetUserIdFromAuth();
if (userId == null)
{
return Unauthorized(Problems("Not authenticated"));
}
var project = await breadboardService.GetProjectAsync(projectId, userId.Value);
if (project == null)
{
return NotFound(Problems(BreadboardResult.NotFoundMessage));
}
return Ok(project);
}
[HttpPut("projects/{projectId:long}")]
[RequestSizeLimit(MaxRequestBodyBytes)]
public async Task<IActionResult> UpdateProject(long projectId, [FromBody] UpdateBreadboardProjectRequest request)
{
var userId = GetUserIdFromAuth();
if (userId == null)
{
return Unauthorized(Problems("Not authenticated"));
}
var result = await breadboardService.UpdateProjectAsync(
projectId,
userId.Value,
request.Name,
request.Description,
RawCircuit(request.Circuit));
return MapResult(result);
}
[HttpDelete("projects/{projectId:long}")]
public async Task<IActionResult> DeleteProject(long projectId)
{
var userId = GetUserIdFromAuth();
if (userId == null)
{
return Unauthorized(Problems("Not authenticated"));
}
var result = await breadboardService.DeleteProjectAsync(projectId, userId.Value);
return MapResult(result);
}
/// <summary>A project that belongs to someone else is reported as missing, never as forbidden.</summary>
private IActionResult MapResult(BreadboardResult result) => result.Outcome switch
{
BreadboardOutcome.Success => result.Project is null ? NoContent() : Ok(result.Project),
BreadboardOutcome.NotFound => NotFound(new { errors = result.Errors }),
BreadboardOutcome.Invalid => BadRequest(new { errors = result.Errors }),
_ => StatusCode(StatusCodes.Status500InternalServerError, new { errors = result.Errors })
};
private static object Problems(string message) => new { errors = new[] { message } };
/// <summary>
/// An omitted circuit and an explicit JSON null both mean "leave the circuit alone".
/// Anything else is handed to the service as raw text — the server never reshapes the document.
/// </summary>
private static string? RawCircuit(JsonElement? circuit) =>
circuit is { ValueKind: not JsonValueKind.Undefined and not JsonValueKind.Null } element
? element.GetRawText()
: null;
/// <summary>
/// JWT first, then the session cookie — the repo-wide pattern.
/// CSRF note: the session path carries no antiforgery token, and is safe today only
/// because of three framework defaults — session cookies are SameSite=Lax, no CORS policy
/// is registered, and an application/json body forces a preflight. Adding a permissive
/// CORS policy or SameSite=None anywhere in this app makes these writes CSRF-able.
/// </summary>
private long? GetUserIdFromAuth()
{
var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!string.IsNullOrEmpty(userIdClaim) && long.TryParse(userIdClaim, out var jwtUserId))
return jwtUserId;
var userIdString = HttpContext.Session.GetString("UserId");
if (!string.IsNullOrEmpty(userIdString) && long.TryParse(userIdString, out var sessionUserId))
return sessionUserId;
return null;
}
}
public record CreateBreadboardProjectRequest(string? Name, string? Description);
public record UpdateBreadboardProjectRequest(string? Name, string? Description, JsonElement? Circuit);
+138 -48
View File
@@ -17,7 +17,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
if (userId == null) return Unauthorized(); if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid(); if (!await HasMedicalAccess(userId.Value)) return Forbid();
var people = await medicalDocsService.GetPeopleAsync(); var people = await medicalDocsService.GetPeopleAsync(userId.Value);
return Ok(people); return Ok(people);
} }
@@ -31,13 +31,68 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
if (string.IsNullOrWhiteSpace(request.Name)) if (string.IsNullOrWhiteSpace(request.Name))
return BadRequest(new { error = "Name is required" }); return BadRequest(new { error = "Name is required" });
var person = await medicalDocsService.CreatePersonAsync(request.Name.Trim(), request.DateOfBirth, request.Notes); var person = await medicalDocsService.CreatePersonAsync(userId.Value, request.Name.Trim(), request.DateOfBirth, request.Notes);
if (person == null) if (person == null)
return StatusCode(500, new { error = "Failed to create person" }); return StatusCode(500, new { error = "Failed to create person" });
return Ok(person); return Ok(person);
} }
// --- People Access ---
[HttpGet("people/{personId}/access")]
public async Task<IActionResult> GetPersonAccess(long personId)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasPersonAccess(userId.Value, personId)) return Forbid();
var users = await medicalDocsService.GetPeopleAccessAsync(personId);
return Ok(users);
}
[HttpPost("people/{personId}/access")]
public async Task<IActionResult> GrantPersonAccess(long personId, [FromBody] GrantAccessRequest request)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasPersonAccess(userId.Value, personId)) return Forbid();
if (string.IsNullOrWhiteSpace(request.Username))
return BadRequest(new { error = "Username is required" });
var targetUser = await dbExecutor.ExecuteReaderAsync(
"SELECT id FROM app.users WHERE LOWER(username) = LOWER(@username)",
reader => reader.GetInt64(0),
new { username = request.Username.Trim() });
if (targetUser == 0)
return NotFound(new { error = "User not found" });
var success = await medicalDocsService.GrantAccessAsync(personId, targetUser);
if (!success)
return StatusCode(500, new { error = "Failed to grant access" });
return Ok(new { success = true });
}
[HttpDelete("people/{personId}/access/{targetUserId}")]
public async Task<IActionResult> RevokePersonAccess(long personId, long targetUserId)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasPersonAccess(userId.Value, personId)) return Forbid();
var success = await medicalDocsService.RevokeAccessAsync(personId, targetUserId);
if (!success)
return BadRequest(new { error = "Cannot revoke access — at least one user must have access" });
return Ok(new { success = true });
}
// --- Documents --- // --- Documents ---
[HttpGet("documents")] [HttpGet("documents")]
@@ -46,6 +101,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
var userId = GetUserIdFromAuth(); var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized(); if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid(); if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid();
if (limit < 1 || limit > 100) limit = 50; if (limit < 1 || limit > 100) limit = 50;
if (offset < 0) offset = 0; if (offset < 0) offset = 0;
@@ -56,7 +112,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
[HttpGet("documents/search")] [HttpGet("documents/search")]
public async Task<IActionResult> SearchDocuments( public async Task<IActionResult> SearchDocuments(
[FromQuery] long personId, [FromQuery] long? personId = null,
[FromQuery] string? search = null, [FromQuery] string? search = null,
[FromQuery] string? classification = null, [FromQuery] string? classification = null,
[FromQuery] string? documentType = null, [FromQuery] string? documentType = null,
@@ -72,28 +128,24 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
var userId = GetUserIdFromAuth(); var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized(); if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid(); if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid();
if (personId <= 0)
return BadRequest(new { error = "personId is required" });
if (limit < 1 || limit > 100) limit = 50; if (limit < 1 || limit > 100) limit = 50;
if (offset < 0) offset = 0; if (offset < 0) offset = 0;
var documents = await medicalDocsService.SearchDocumentsAsync(personId, search, classification, documentType, doctorId, tagId, conditionId, fromDate, toDate, aiProcessed, offset, limit); var documents = await medicalDocsService.SearchDocumentsAsync(personId, search, classification, documentType, doctorId, tagId, conditionId, fromDate, toDate, aiProcessed, accessUserId: personId.HasValue ? null : userId, offset: offset, limit: limit);
return Ok(documents); return Ok(documents);
} }
[HttpGet("tags")] [HttpGet("tags")]
public async Task<IActionResult> GetPersonTags([FromQuery] long personId) public async Task<IActionResult> GetPersonTags([FromQuery] long? personId = null)
{ {
var userId = GetUserIdFromAuth(); var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized(); if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid(); if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid();
if (personId <= 0) var tags = await medicalDocsService.GetPersonTagsAsync(personId, accessUserId: personId.HasValue ? null : userId);
return BadRequest(new { error = "personId is required" });
var tags = await medicalDocsService.GetPersonTagsAsync(personId);
return Ok(tags); return Ok(tags);
} }
@@ -104,6 +156,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
var userId = GetUserIdFromAuth(); var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized(); if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid(); if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasPersonAccess(userId.Value, personId)) return Forbid();
if (file == null || file.Length == 0) if (file == null || file.Length == 0)
return BadRequest(new { error = "No file provided" }); return BadRequest(new { error = "No file provided" });
@@ -126,6 +179,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
if (request.PersonId <= 0) if (request.PersonId <= 0)
return BadRequest(new { error = "Person is required" }); return BadRequest(new { error = "Person is required" });
if (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid();
if (string.IsNullOrWhiteSpace(request.Title)) if (string.IsNullOrWhiteSpace(request.Title))
return BadRequest(new { error = "Title is required" }); return BadRequest(new { error = "Title is required" });
@@ -144,6 +198,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
var userId = GetUserIdFromAuth(); var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized(); if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid(); if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "document", id)) return Forbid();
var doc = await medicalDocsService.GetDocumentByIdAsync(id); var doc = await medicalDocsService.GetDocumentByIdAsync(id);
if (doc == null) return NotFound(new { error = "Document not found" }); if (doc == null) return NotFound(new { error = "Document not found" });
@@ -157,6 +212,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
var userId = GetUserIdFromAuth(); var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized(); if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid(); if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "document", id)) return Forbid();
var doc = await medicalDocsService.GetDocumentByIdAsync(id); var doc = await medicalDocsService.GetDocumentByIdAsync(id);
if (doc == null) return NotFound(new { error = "Document not found" }); if (doc == null) return NotFound(new { error = "Document not found" });
@@ -176,6 +232,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
var userId = GetUserIdFromAuth(); var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized(); if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid(); if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "document", id)) return Forbid();
var success = await medicalDocsService.UpdateDocumentAsync(id, request.Title, request.Description, request.DocumentDate, request.Classification, request.DoctorId); var success = await medicalDocsService.UpdateDocumentAsync(id, request.Title, request.Description, request.DocumentDate, request.Classification, request.DoctorId);
if (!success) return NotFound(new { error = "Document not found" }); if (!success) return NotFound(new { error = "Document not found" });
@@ -189,6 +246,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
var userId = GetUserIdFromAuth(); var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized(); if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid(); if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "document", id)) return Forbid();
var success = await medicalDocsService.DeleteDocumentAsync(id); var success = await medicalDocsService.DeleteDocumentAsync(id);
if (!success) return NotFound(new { error = "Document not found" }); if (!success) return NotFound(new { error = "Document not found" });
@@ -204,6 +262,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
var userId = GetUserIdFromAuth(); var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized(); if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid(); if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "document", id)) return Forbid();
var doc = await medicalDocsService.GetDocumentByIdAsync(id); var doc = await medicalDocsService.GetDocumentByIdAsync(id);
if (doc == null) return NotFound(new { error = "Document not found" }); if (doc == null) return NotFound(new { error = "Document not found" });
@@ -260,21 +319,23 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
var userId = GetUserIdFromAuth(); var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized(); if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid(); if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "document", id)) return Forbid();
var tags = await medicalDocsService.GetDocumentTagsAsync(id); var tags = await medicalDocsService.GetDocumentTagsAsync(id);
return Ok(tags); return Ok(tags);
} }
// --- Doctors --- // --- Doctors (shared, no per-person access check) ---
[HttpGet("doctors")] [HttpGet("doctors")]
public async Task<IActionResult> GetDoctors() public async Task<IActionResult> GetDoctors([FromQuery] long? personId = null)
{ {
var userId = GetUserIdFromAuth(); var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized(); if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid(); if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid();
var doctors = await medicalDocsService.GetDoctorsAsync(); var doctors = await medicalDocsService.GetDoctorsAsync(personId, accessUserId: personId.HasValue ? null : userId);
return Ok(doctors); return Ok(doctors);
} }
@@ -284,11 +345,12 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
var userId = GetUserIdFromAuth(); var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized(); if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid(); if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid();
if (string.IsNullOrWhiteSpace(request.Name)) if (string.IsNullOrWhiteSpace(request.Name))
return BadRequest(new { error = "Name is required" }); return BadRequest(new { error = "Name is required" });
var doctor = await medicalDocsService.CreateDoctorAsync(request.Name.Trim(), request.Specialty, request.Phone, request.Address, request.Notes); var doctor = await medicalDocsService.CreateDoctorAsync(request.PersonId, request.Name.Trim(), request.Specialty, request.Phone, request.Address, request.Notes);
if (doctor == null) if (doctor == null)
return StatusCode(500, new { error = "Failed to create doctor" }); return StatusCode(500, new { error = "Failed to create doctor" });
@@ -301,6 +363,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
var userId = GetUserIdFromAuth(); var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized(); if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid(); if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "doctor", id)) return Forbid();
if (string.IsNullOrWhiteSpace(request.Name)) if (string.IsNullOrWhiteSpace(request.Name))
return BadRequest(new { error = "Name is required" }); return BadRequest(new { error = "Name is required" });
@@ -317,6 +380,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
var userId = GetUserIdFromAuth(); var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized(); if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid(); if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "doctor", id)) return Forbid();
var success = await medicalDocsService.DeleteDoctorAsync(id); var success = await medicalDocsService.DeleteDoctorAsync(id);
if (!success) return NotFound(new { error = "Doctor not found" }); if (!success) return NotFound(new { error = "Doctor not found" });
@@ -327,16 +391,14 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
// --- Conditions --- // --- Conditions ---
[HttpGet("conditions")] [HttpGet("conditions")]
public async Task<IActionResult> GetConditions([FromQuery] long personId) public async Task<IActionResult> GetConditions([FromQuery] long? personId = null)
{ {
var userId = GetUserIdFromAuth(); var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized(); if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid(); if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid();
if (personId <= 0) var conditions = await medicalDocsService.GetConditionsAsync(personId, accessUserId: personId.HasValue ? null : userId);
return BadRequest(new { error = "personId is required" });
var conditions = await medicalDocsService.GetConditionsAsync(personId);
return Ok(conditions); return Ok(conditions);
} }
@@ -349,6 +411,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
if (request.PersonId <= 0) if (request.PersonId <= 0)
return BadRequest(new { error = "Person is required" }); return BadRequest(new { error = "Person is required" });
if (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid();
if (string.IsNullOrWhiteSpace(request.Name)) if (string.IsNullOrWhiteSpace(request.Name))
return BadRequest(new { error = "Name is required" }); return BadRequest(new { error = "Name is required" });
@@ -365,6 +428,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
var userId = GetUserIdFromAuth(); var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized(); if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid(); if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "condition", id)) return Forbid();
if (string.IsNullOrWhiteSpace(request.Name)) if (string.IsNullOrWhiteSpace(request.Name))
return BadRequest(new { error = "Name is required" }); return BadRequest(new { error = "Name is required" });
@@ -381,6 +445,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
var userId = GetUserIdFromAuth(); var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized(); if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid(); if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "condition", id)) return Forbid();
var success = await medicalDocsService.DeleteConditionAsync(id); var success = await medicalDocsService.DeleteConditionAsync(id);
if (!success) return NotFound(new { error = "Condition not found" }); if (!success) return NotFound(new { error = "Condition not found" });
@@ -391,16 +456,14 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
// --- Prescriptions --- // --- Prescriptions ---
[HttpGet("prescriptions")] [HttpGet("prescriptions")]
public async Task<IActionResult> GetPrescriptions([FromQuery] long personId) public async Task<IActionResult> GetPrescriptions([FromQuery] long? personId = null)
{ {
var userId = GetUserIdFromAuth(); var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized(); if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid(); if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid();
if (personId <= 0) var prescriptions = await medicalDocsService.GetPrescriptionsAsync(personId, accessUserId: personId.HasValue ? null : userId);
return BadRequest(new { error = "personId is required" });
var prescriptions = await medicalDocsService.GetPrescriptionsAsync(personId);
return Ok(prescriptions); return Ok(prescriptions);
} }
@@ -413,6 +476,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
if (request.PersonId <= 0) if (request.PersonId <= 0)
return BadRequest(new { error = "Person is required" }); return BadRequest(new { error = "Person is required" });
if (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid();
if (string.IsNullOrWhiteSpace(request.MedicationName)) if (string.IsNullOrWhiteSpace(request.MedicationName))
return BadRequest(new { error = "Medication name is required" }); return BadRequest(new { error = "Medication name is required" });
@@ -429,6 +493,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
var userId = GetUserIdFromAuth(); var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized(); if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid(); if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "prescription", id)) return Forbid();
if (string.IsNullOrWhiteSpace(request.MedicationName)) if (string.IsNullOrWhiteSpace(request.MedicationName))
return BadRequest(new { error = "Medication name is required" }); return BadRequest(new { error = "Medication name is required" });
@@ -445,6 +510,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
var userId = GetUserIdFromAuth(); var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized(); if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid(); if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "prescription", id)) return Forbid();
var success = await medicalDocsService.DeletePrescriptionAsync(id); var success = await medicalDocsService.DeletePrescriptionAsync(id);
if (!success) return NotFound(new { error = "Prescription not found" }); if (!success) return NotFound(new { error = "Prescription not found" });
@@ -460,6 +526,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
var userId = GetUserIdFromAuth(); var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized(); if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid(); if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "prescription", id)) return Forbid();
var pickups = await medicalDocsService.GetPickupsAsync(id); var pickups = await medicalDocsService.GetPickupsAsync(id);
return Ok(pickups); return Ok(pickups);
@@ -471,6 +538,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
var userId = GetUserIdFromAuth(); var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized(); if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid(); if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "prescription", id)) return Forbid();
var pickup = await medicalDocsService.CreatePickupAsync(id, request.PickupDate, request.Quantity, request.Pharmacy, request.Cost, request.Notes); var pickup = await medicalDocsService.CreatePickupAsync(id, request.PickupDate, request.Quantity, request.Pharmacy, request.Cost, request.Notes);
if (pickup == null) if (pickup == null)
@@ -485,6 +553,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
var userId = GetUserIdFromAuth(); var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized(); if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid(); if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "pickup", id)) return Forbid();
var success = await medicalDocsService.DeletePickupAsync(id); var success = await medicalDocsService.DeletePickupAsync(id);
if (!success) return NotFound(new { error = "Pickup not found" }); if (!success) return NotFound(new { error = "Pickup not found" });
@@ -495,16 +564,14 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
// --- Billing Providers --- // --- Billing Providers ---
[HttpGet("providers")] [HttpGet("providers")]
public async Task<IActionResult> GetProviders([FromQuery] long personId) public async Task<IActionResult> GetProviders([FromQuery] long? personId = null)
{ {
var userId = GetUserIdFromAuth(); var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized(); if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid(); if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid();
if (personId <= 0) var providers = await medicalDocsService.GetProvidersAsync(personId, accessUserId: personId.HasValue ? null : userId);
return BadRequest(new { error = "personId is required" });
var providers = await medicalDocsService.GetProvidersAsync(personId);
return Ok(providers); return Ok(providers);
} }
@@ -517,6 +584,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
if (request.PersonId <= 0) if (request.PersonId <= 0)
return BadRequest(new { error = "Person is required" }); return BadRequest(new { error = "Person is required" });
if (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid();
if (string.IsNullOrWhiteSpace(request.Name)) if (string.IsNullOrWhiteSpace(request.Name))
return BadRequest(new { error = "Name is required" }); return BadRequest(new { error = "Name is required" });
@@ -533,6 +601,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
var userId = GetUserIdFromAuth(); var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized(); if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid(); if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "provider", id)) return Forbid();
if (string.IsNullOrWhiteSpace(request.Name)) if (string.IsNullOrWhiteSpace(request.Name))
return BadRequest(new { error = "Name is required" }); return BadRequest(new { error = "Name is required" });
@@ -549,6 +618,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
var userId = GetUserIdFromAuth(); var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized(); if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid(); if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "provider", id)) return Forbid();
var success = await medicalDocsService.DeleteProviderAsync(id); var success = await medicalDocsService.DeleteProviderAsync(id);
if (!success) return NotFound(new { error = "Provider not found" }); if (!success) return NotFound(new { error = "Provider not found" });
@@ -564,6 +634,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
var userId = GetUserIdFromAuth(); var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized(); if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid(); if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "provider", id)) return Forbid();
var payments = await medicalDocsService.GetProviderPaymentsAsync(id); var payments = await medicalDocsService.GetProviderPaymentsAsync(id);
return Ok(payments); return Ok(payments);
@@ -575,6 +646,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
var userId = GetUserIdFromAuth(); var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized(); if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid(); if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "provider", id)) return Forbid();
if (request.Amount <= 0) if (request.Amount <= 0)
return BadRequest(new { error = "Amount must be greater than 0" }); return BadRequest(new { error = "Amount must be greater than 0" });
@@ -592,6 +664,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
var userId = GetUserIdFromAuth(); var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized(); if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid(); if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "provider-payment", id)) return Forbid();
var success = await medicalDocsService.DeleteProviderPaymentAsync(id); var success = await medicalDocsService.DeleteProviderPaymentAsync(id);
if (!success) return NotFound(new { error = "Payment not found" }); if (!success) return NotFound(new { error = "Payment not found" });
@@ -602,16 +675,14 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
// --- Bills --- // --- Bills ---
[HttpGet("bills")] [HttpGet("bills")]
public async Task<IActionResult> GetBills([FromQuery] long personId, [FromQuery] long? providerId = null) public async Task<IActionResult> GetBills([FromQuery] long? personId = null, [FromQuery] long? providerId = null)
{ {
var userId = GetUserIdFromAuth(); var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized(); if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid(); if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid();
if (personId <= 0) var bills = await medicalDocsService.GetBillsAsync(personId, providerId, accessUserId: personId.HasValue ? null : userId);
return BadRequest(new { error = "personId is required" });
var bills = await medicalDocsService.GetBillsAsync(personId, providerId);
return Ok(bills); return Ok(bills);
} }
@@ -624,6 +695,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
if (request.PersonId <= 0) if (request.PersonId <= 0)
return BadRequest(new { error = "Person is required" }); return BadRequest(new { error = "Person is required" });
if (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid();
if (request.TotalAmount <= 0) if (request.TotalAmount <= 0)
return BadRequest(new { error = "Amount must be greater than 0" }); return BadRequest(new { error = "Amount must be greater than 0" });
@@ -640,6 +712,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
var userId = GetUserIdFromAuth(); var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized(); if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid(); if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "bill", id)) return Forbid();
if (request.TotalAmount <= 0) if (request.TotalAmount <= 0)
return BadRequest(new { error = "Amount must be greater than 0" }); return BadRequest(new { error = "Amount must be greater than 0" });
@@ -656,6 +729,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
var userId = GetUserIdFromAuth(); var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized(); if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid(); if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "bill", id)) return Forbid();
var success = await medicalDocsService.DeleteBillAsync(id); var success = await medicalDocsService.DeleteBillAsync(id);
if (!success) return NotFound(new { error = "Bill not found" }); if (!success) return NotFound(new { error = "Bill not found" });
@@ -669,6 +743,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
var userId = GetUserIdFromAuth(); var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized(); if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid(); if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "bill", id)) return Forbid();
if (request.DocumentId <= 0) if (request.DocumentId <= 0)
return BadRequest(new { error = "Document is required" }); return BadRequest(new { error = "Document is required" });
@@ -686,6 +761,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
var userId = GetUserIdFromAuth(); var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized(); if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid(); if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "bill", billId)) return Forbid();
var success = await medicalDocsService.UnlinkDocumentFromBillAsync(billId, docId); var success = await medicalDocsService.UnlinkDocumentFromBillAsync(billId, docId);
if (!success) return NotFound(new { error = "Link not found" }); if (!success) return NotFound(new { error = "Link not found" });
@@ -701,6 +777,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
var userId = GetUserIdFromAuth(); var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized(); if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid(); if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "bill", id)) return Forbid();
var charges = await medicalDocsService.GetChargesAsync(id); var charges = await medicalDocsService.GetChargesAsync(id);
return Ok(charges); return Ok(charges);
@@ -712,6 +789,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
var userId = GetUserIdFromAuth(); var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized(); if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid(); if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "bill", id)) return Forbid();
if (string.IsNullOrWhiteSpace(request.Description)) if (string.IsNullOrWhiteSpace(request.Description))
return BadRequest(new { error = "Description is required" }); return BadRequest(new { error = "Description is required" });
@@ -731,6 +809,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
var userId = GetUserIdFromAuth(); var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized(); if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid(); if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (!await HasResourceAccess(userId.Value, "bill-charge", id)) return Forbid();
var success = await medicalDocsService.DeleteChargeAsync(id); var success = await medicalDocsService.DeleteChargeAsync(id);
if (!success) return NotFound(new { error = "Charge not found" }); if (!success) return NotFound(new { error = "Charge not found" });
@@ -741,19 +820,17 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
// --- Timeline --- // --- Timeline ---
[HttpGet("timeline")] [HttpGet("timeline")]
public async Task<IActionResult> GetTimeline([FromQuery] long personId, [FromQuery] int offset = 0, [FromQuery] int limit = 100) public async Task<IActionResult> GetTimeline([FromQuery] long? personId = null, [FromQuery] int offset = 0, [FromQuery] int limit = 100)
{ {
var userId = GetUserIdFromAuth(); var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized(); if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid(); if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid();
if (personId <= 0)
return BadRequest(new { error = "personId is required" });
if (limit < 1 || limit > 200) limit = 100; if (limit < 1 || limit > 200) limit = 100;
if (offset < 0) offset = 0; if (offset < 0) offset = 0;
var events = await medicalDocsService.GetTimelineAsync(personId, offset, limit); var events = await medicalDocsService.GetTimelineAsync(personId, accessUserId: personId.HasValue ? null : userId, offset: offset, limit: limit);
return Ok(events); return Ok(events);
} }
@@ -768,6 +845,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
if (personId <= 0 || doctorId <= 0) if (personId <= 0 || doctorId <= 0)
return BadRequest(new { error = "personId and doctorId are required" }); return BadRequest(new { error = "personId and doctorId are required" });
if (!await HasPersonAccess(userId.Value, personId)) return Forbid();
var data = await medicalDocsService.GetVisitPrepAsync(personId, doctorId); var data = await medicalDocsService.GetVisitPrepAsync(personId, doctorId);
return Ok(data); return Ok(data);
@@ -782,6 +860,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
if (request.PersonId <= 0 || request.DoctorId <= 0) if (request.PersonId <= 0 || request.DoctorId <= 0)
return BadRequest(new { error = "personId and doctorId are required" }); return BadRequest(new { error = "personId and doctorId are required" });
if (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid();
var data = await medicalDocsService.GetVisitPrepAsync(request.PersonId, request.DoctorId); var data = await medicalDocsService.GetVisitPrepAsync(request.PersonId, request.DoctorId);
var doctor = await medicalDocsService.GetDoctorByIdAsync(request.DoctorId); var doctor = await medicalDocsService.GetDoctorByIdAsync(request.DoctorId);
@@ -793,20 +872,18 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
} }
[HttpGet("bills/summary")] [HttpGet("bills/summary")]
public async Task<IActionResult> GetBillSummary([FromQuery] long personId) public async Task<IActionResult> GetBillSummary([FromQuery] long? personId = null)
{ {
var userId = GetUserIdFromAuth(); var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized(); if (userId == null) return Unauthorized();
if (!await HasMedicalAccess(userId.Value)) return Forbid(); if (!await HasMedicalAccess(userId.Value)) return Forbid();
if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid();
if (personId <= 0) var summary = await medicalDocsService.GetBillSummaryAsync(personId, accessUserId: personId.HasValue ? null : userId);
return BadRequest(new { error = "personId is required" });
var summary = await medicalDocsService.GetBillSummaryAsync(personId);
return Ok(summary); return Ok(summary);
} }
// --- Auth helpers (same pattern as AdminApi) --- // --- Auth helpers ---
private async Task<bool> HasMedicalAccess(long userId) private async Task<bool> HasMedicalAccess(long userId)
{ {
@@ -815,6 +892,18 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
new { UserId = userId }); new { UserId = userId });
} }
private async Task<bool> HasPersonAccess(long userId, long personId)
{
return await medicalDocsService.HasAccessToPersonAsync(userId, personId);
}
private async Task<bool> HasResourceAccess(long userId, string resourceType, long resourceId)
{
var personId = await medicalDocsService.GetPersonIdForResourceAsync(resourceType, resourceId);
if (personId == null) return false;
return await medicalDocsService.HasAccessToPersonAsync(userId, personId.Value);
}
private long? GetUserIdFromAuth() private long? GetUserIdFromAuth()
{ {
var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
@@ -836,7 +925,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
public record CreatePersonRequest(string Name, DateTime? DateOfBirth = null, string? Notes = null); public record CreatePersonRequest(string Name, DateTime? DateOfBirth = null, string? Notes = null);
public record CreateNoteRequest(long PersonId, string Title, string? Description = null, DateTime? DocumentDate = null, string? Classification = null); public record CreateNoteRequest(long PersonId, string Title, string? Description = null, DateTime? DocumentDate = null, string? Classification = null);
public record UpdateDocumentRequest(string? Title = null, string? Description = null, DateTime? DocumentDate = null, string? Classification = null, long? DoctorId = null); public record UpdateDocumentRequest(string? Title = null, string? Description = null, DateTime? DocumentDate = null, string? Classification = null, long? DoctorId = null);
public record CreateDoctorRequest(string Name, string? Specialty = null, string? Phone = null, string? Address = null, string? Notes = null); public record CreateDoctorRequest(long PersonId, string Name, string? Specialty = null, string? Phone = null, string? Address = null, string? Notes = null);
public record CreateConditionRequest(long PersonId, string Name, DateTime? DiagnosedDate = null, string? Notes = null); public record CreateConditionRequest(long PersonId, string Name, DateTime? DiagnosedDate = null, string? Notes = null);
public record UpdateConditionRequest(string Name, DateTime? DiagnosedDate = null, string? Notes = null, bool IsActive = true); public record UpdateConditionRequest(string Name, DateTime? DiagnosedDate = null, string? Notes = null, bool IsActive = true);
public record CreatePrescriptionRequest(long PersonId, string MedicationName, string? Dosage = null, string? Frequency = null, long? DoctorId = null, DateTime? StartDate = null, string? Notes = null, string? RxNumber = null); public record CreatePrescriptionRequest(long PersonId, string MedicationName, string? Dosage = null, string? Frequency = null, long? DoctorId = null, DateTime? StartDate = null, string? Notes = null, string? RxNumber = null);
@@ -851,3 +940,4 @@ public record LinkDocumentRequest(long DocumentId);
public record CreateChargeRequest(string Description, decimal Amount); public record CreateChargeRequest(string Description, decimal Amount);
public record ProcessBatchRequest(List<long> DocumentIds); public record ProcessBatchRequest(List<long> DocumentIds);
public record VisitPrepSummaryRequest(long PersonId, long DoctorId); public record VisitPrepSummaryRequest(long PersonId, long DoctorId);
public record GrantAccessRequest(string Username);
+190
View File
@@ -0,0 +1,190 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using Media.JoshHeaps.Net.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using Npgsql;
namespace Media.JoshHeaps.Net.Api;
[ApiController]
[Route("sso")]
public class SsoApi(DbExecutor db, IConfiguration config, ILogger<SsoApi> logger) : ControllerBase
{
[HttpPost("token")]
public async Task<IActionResult> Exchange([FromBody] SsoTokenRequest request)
{
if (request == null || string.IsNullOrWhiteSpace(request.ClientId) || string.IsNullOrWhiteSpace(request.Code))
{
return BadRequest(new { error = "client_id and code are required" });
}
if (!Request.Headers.TryGetValue("X-Client-Secret", out var providedSecret) || string.IsNullOrWhiteSpace(providedSecret))
{
return Unauthorized(new { error = "missing client credentials" });
}
var client = SsoClientRegistry.Find(config, request.ClientId);
if (client == null || !BCrypt.Net.BCrypt.Verify(providedSecret!, client.ClientSecretHash))
{
logger.LogWarning("SSO token exchange failed: bad client credentials for {ClientId}", request.ClientId);
return Unauthorized(new { error = "invalid client credentials" });
}
var codeHash = HashCode(request.Code);
var row = await ConsumeCodeAsync(codeHash);
if (row == null)
{
return BadRequest(new { error = "invalid, expired, or already-used code" });
}
if (!string.Equals(row.Value.ClientId, request.ClientId, StringComparison.Ordinal))
{
return BadRequest(new { error = "code was issued for a different client" });
}
if (!client.AllowsRedirectUri(row.Value.RedirectUri))
{
return BadRequest(new { error = "redirect_uri mismatch" });
}
var user = await LoadUserAsync(row.Value.UserId);
if (user == null)
{
return BadRequest(new { error = "user no longer exists" });
}
var jwt = await IssueTokenAsync(user, request.ClientId);
return Ok(new SsoTokenResponse
{
AccessToken = jwt,
TokenType = "Bearer",
ExpiresIn = 300
});
}
private async Task<(long UserId, string ClientId, string RedirectUri)?> ConsumeCodeAsync(string codeHash)
{
var connectionString = config["connectionString"]!;
await using var conn = new NpgsqlConnection(connectionString);
await conn.OpenAsync();
await using var tx = await conn.BeginTransactionAsync();
long userId;
string clientId;
string redirectUri;
await using (var select = new NpgsqlCommand(
@"SELECT user_id, client_id, redirect_uri
FROM app.sso_authorization_codes
WHERE code_hash = @h
AND consumed_at IS NULL
AND expires_at > NOW()
FOR UPDATE", conn, tx))
{
select.Parameters.AddWithValue("@h", codeHash);
await using var reader = await select.ExecuteReaderAsync();
if (!await reader.ReadAsync()) return null;
userId = reader.GetInt64(0);
clientId = reader.GetString(1);
redirectUri = reader.GetString(2);
}
await using (var update = new NpgsqlCommand(
"UPDATE app.sso_authorization_codes SET consumed_at = NOW() WHERE code_hash = @h",
conn, tx))
{
update.Parameters.AddWithValue("@h", codeHash);
await update.ExecuteNonQueryAsync();
}
await tx.CommitAsync();
return (userId, clientId, redirectUri);
}
private async Task<SsoUser?> LoadUserAsync(long userId)
{
return await db.ExecuteReaderAsync(
"SELECT id, email, username, email_verified FROM app.users WHERE id = @userId AND is_active = true",
reader => new SsoUser
{
Id = reader.GetInt64(0),
Email = reader.GetString(1),
Username = reader.GetString(2),
EmailVerified = reader.GetBoolean(3)
},
new { userId });
}
private async Task<string> IssueTokenAsync(SsoUser user, string audience)
{
var jwtKey = config["Jwt:Key"] ?? throw new InvalidOperationException("JWT Key not configured");
var jwtIssuer = config["Jwt:Issuer"] ?? throw new InvalidOperationException("JWT Issuer not configured");
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey));
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
var roles = await LoadUserRolesAsync(user.Id);
var claims = new List<Claim>
{
new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Email, user.Email),
new Claim(ClaimTypes.Name, user.Username),
new Claim("EmailVerified", user.EmailVerified.ToString()),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N"))
};
claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r)));
var token = new JwtSecurityToken(
issuer: jwtIssuer,
audience: audience,
claims: claims,
expires: DateTime.UtcNow.AddMinutes(5),
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
private async Task<List<string>> LoadUserRolesAsync(long userId)
{
return await db.ExecuteListReaderAsync(
@"SELECT r.name
FROM app.user_roles ur
JOIN app.roles r ON ur.role_id = r.id
WHERE ur.user_id = @userId",
reader => reader.GetString(0),
new { userId });
}
private static string HashCode(string code)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(code));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
}
public sealed class SsoTokenRequest
{
public string ClientId { get; set; } = string.Empty;
public string Code { get; set; } = string.Empty;
}
public sealed class SsoTokenResponse
{
public string AccessToken { get; set; } = string.Empty;
public string TokenType { get; set; } = "Bearer";
public int ExpiresIn { get; set; }
}
internal sealed class SsoUser
{
public long Id { get; set; }
public string Email { get; set; } = string.Empty;
public string Username { get; set; } = string.Empty;
public bool EmailVerified { get; set; }
}
+74
View File
@@ -0,0 +1,74 @@
using System.Text.RegularExpressions;
using Media.JoshHeaps.Net.Services;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
namespace Media.JoshHeaps.Net.Api;
[ApiController]
[Route("api/theme")]
public partial class ThemeApi(ThemeService themeService) : ControllerBase
{
private static readonly HashSet<string> ValidCssVariables =
[
"--bg-primary", "--bg-secondary", "--bg-tertiary", "--bg-hover",
"--text-primary", "--text-secondary",
"--border-primary", "--border-secondary",
"--accent-primary", "--accent-hover",
"--danger", "--danger-hover", "--success"
];
[GeneratedRegex(@"^#[0-9a-fA-F]{6}$")]
private static partial Regex HexColorRegex();
[HttpGet("my")]
public async Task<IActionResult> GetMyTheme()
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
var theme = await themeService.GetUserThemeAsync(userId.Value);
if (theme == null)
{
return Ok(new { baseTheme = "light", colorOverrides = new Dictionary<string, string>() });
}
return Ok(new { baseTheme = theme.BaseTheme, colorOverrides = theme.ColorOverrides });
}
[HttpPut("my")]
public async Task<IActionResult> SaveMyTheme([FromBody] SaveThemeRequest request)
{
var userId = GetUserIdFromAuth();
if (userId == null) return Unauthorized();
if (request.BaseTheme != "dark" && request.BaseTheme != "light")
return BadRequest("baseTheme must be 'dark' or 'light'");
foreach (var (key, value) in request.ColorOverrides)
{
if (!ValidCssVariables.Contains(key))
return BadRequest($"Invalid CSS variable: {key}");
if (!HexColorRegex().IsMatch(value))
return BadRequest($"Invalid hex color for {key}: {value}");
}
await themeService.SaveUserThemeAsync(userId.Value, request.BaseTheme, request.ColorOverrides);
return Ok();
}
private long? GetUserIdFromAuth()
{
var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!string.IsNullOrEmpty(userIdClaim) && long.TryParse(userIdClaim, out var jwtUserId))
return jwtUserId;
var userIdString = HttpContext.Session.GetString("UserId");
if (!string.IsNullOrEmpty(userIdString) && long.TryParse(userIdString, out var sessionUserId))
return sessionUserId;
return null;
}
}
public record SaveThemeRequest(string BaseTheme, Dictionary<string, string> ColorOverrides);
@@ -0,0 +1,12 @@
CREATE TABLE app.password_reset_tokens (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
token_hash VARCHAR(64) NOT NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ NULL,
FOREIGN KEY (user_id) REFERENCES app.users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_prt_token_hash ON app.password_reset_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_prt_user_id ON app.password_reset_tokens(user_id);
@@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS app.medical_people_access (
id BIGSERIAL PRIMARY KEY,
person_id BIGINT NOT NULL REFERENCES app.medical_people(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES app.users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE(person_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_mpa_user_id ON app.medical_people_access(user_id);
CREATE INDEX IF NOT EXISTS idx_mpa_person_id ON app.medical_people_access(person_id);
@@ -0,0 +1,7 @@
-- Add person_id to medical_doctors to scope doctors per person
ALTER TABLE app.medical_doctors ADD COLUMN IF NOT EXISTS person_id BIGINT REFERENCES app.medical_people(id);
CREATE INDEX IF NOT EXISTS idx_medical_doctors_person_id ON app.medical_doctors(person_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_medical_doctors_person_name
ON app.medical_doctors(person_id, LOWER(name)) WHERE person_id IS NOT NULL;
@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS app.user_theme_overrides (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES app.users(id) ON DELETE CASCADE,
base_theme TEXT NOT NULL DEFAULT 'light',
color_overrides JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id)
);
CREATE INDEX IF NOT EXISTS idx_uto_user_id ON app.user_theme_overrides(user_id);
@@ -0,0 +1,30 @@
CREATE TABLE IF NOT EXISTS app.blog_posts (
id BIGSERIAL PRIMARY KEY,
slug VARCHAR(200) UNIQUE NOT NULL,
title VARCHAR(500) NOT NULL,
summary TEXT NOT NULL DEFAULT '',
markdown_content TEXT NOT NULL,
html_content TEXT NOT NULL,
tags TEXT[] NOT NULL DEFAULT '{}',
author_id BIGINT NOT NULL REFERENCES app.users(id),
published_date TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_blog_posts_slug ON app.blog_posts(slug);
CREATE INDEX IF NOT EXISTS idx_blog_posts_published_date ON app.blog_posts(published_date DESC);
-- Seed existing blog post
INSERT INTO app.blog_posts (slug, title, summary, markdown_content, html_content, tags, author_id, published_date)
SELECT
'my-first-post',
'My First Post',
'Welcome to my blog! Here I''ll share thoughts on software development, projects I''m working on, and things I find interesting.',
E'# Welcome to My Blog\n\nI''ve been meaning to start writing about the things I build and learn, and I''m finally getting around to it.\n\n## What to Expect\n\nI plan to write about:\n\n- **Projects** I''m working on, like this website and the other things on my portfolio\n- **Software development** tips and patterns I find useful\n- **Problem solving** approaches that have helped me grow as a developer\n\n## Why a Blog?\n\nBuilding 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.\n\nStay tuned for more posts!',
E'<h1>Welcome to My Blog</h1>\n<p>I''ve been meaning to start writing about the things I build and learn, and I''m finally getting around to it.</p>\n<h2>What to Expect</h2>\n<p>I plan to write about:</p>\n<ul>\n<li><strong>Projects</strong> I''m working on, like this website and the other things on my portfolio</li>\n<li><strong>Software development</strong> tips and patterns I find useful</li>\n<li><strong>Problem solving</strong> approaches that have helped me grow as a developer</li>\n</ul>\n<h2>Why a Blog?</h2>\n<p>Building things is great, but explaining <em>how</em> and <em>why</em> you built them is just as valuable. Writing forces you to organize your thoughts, and hopefully someone else finds it useful along the way.</p>\n<p>Stay tuned for more posts!</p>',
ARRAY['dev', 'personal'],
(SELECT id FROM app.users WHERE id = 1),
'2026-03-06T00:00:00Z'
WHERE EXISTS (SELECT 1 FROM app.users WHERE id = 1)
AND NOT EXISTS (SELECT 1 FROM app.blog_posts WHERE slug = 'my-first-post');
@@ -0,0 +1,15 @@
-- SSO authorization codes for the OAuth2 authorization-code flow.
-- The raw code is never stored; we persist the SHA-256 hash only.
-- Codes are single-use and short-lived (see Sso:CodeLifetimeSeconds in config).
CREATE TABLE IF NOT EXISTS app.sso_authorization_codes (
code_hash TEXT PRIMARY KEY,
client_id TEXT NOT NULL,
user_id BIGINT NOT NULL REFERENCES app.users(id) ON DELETE CASCADE,
redirect_uri TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_sso_codes_expires ON app.sso_authorization_codes(expires_at);
@@ -0,0 +1,26 @@
-- Breadboard circuit simulator.
-- A project owns one circuit document (schema v1) stored as JSONB: boards,
-- components and wires laid out on full-size 830-point breadboards.
-- Memory images hold the contents of memory components keyed by the component's
-- uid within the circuit document; no endpoints use them yet.
CREATE TABLE IF NOT EXISTS app.breadboard_projects (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES app.users(id),
name TEXT NOT NULL,
description TEXT,
circuit JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_breadboard_projects_user_id ON app.breadboard_projects(user_id);
CREATE TABLE IF NOT EXISTS app.breadboard_memory_images (
id BIGSERIAL PRIMARY KEY,
project_id BIGINT NOT NULL REFERENCES app.breadboard_projects(id) ON DELETE CASCADE,
component_uid TEXT NOT NULL,
data BYTEA NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_breadboard_memory_images UNIQUE (project_id, component_uid)
);
@@ -10,6 +10,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" /> <PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="MailKit" Version="4.8.0" /> <PackageReference Include="MailKit" Version="4.8.0" />
<PackageReference Include="Markdig" Version="1.1.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
<PackageReference Include="Npgsql" Version="9.0.4" /> <PackageReference Include="Npgsql" Version="9.0.4" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" /> <PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
+16
View File
@@ -0,0 +1,16 @@
namespace Media.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 string Summary { get; set; } = string.Empty;
public string MarkdownContent { get; set; } = string.Empty;
public string HtmlContent { get; set; } = string.Empty;
public List<string> Tags { get; set; } = [];
public long AuthorId { get; set; }
public DateTime PublishedDate { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}
@@ -0,0 +1,24 @@
using System.Text.Json.Nodes;
namespace Media.JoshHeaps.Net.Models;
/// <summary>Row shape for the project list — deliberately excludes the circuit document.</summary>
public sealed record BreadboardProjectSummary(
long Id,
string Name,
string? Description,
DateTime CreatedAt,
DateTime UpdatedAt);
/// <summary>
/// A single project including its circuit document. The circuit is opaque to C# —
/// it is stored and validated as text and only parsed here so the API emits it as a
/// real JSON object rather than a JSON-encoded string.
/// </summary>
public sealed record BreadboardProject(
long Id,
string Name,
string? Description,
JsonNode? Circuit,
DateTime CreatedAt,
DateTime UpdatedAt);
@@ -0,0 +1,35 @@
namespace Media.JoshHeaps.Net.Models;
public enum BreadboardOutcome
{
Success,
NotFound,
Invalid,
Failed
}
/// <summary>
/// Outcome of a write against a breadboard project. The API layer maps the outcome to a
/// status code; every rule that produces <see cref="BreadboardOutcome.Invalid"/> lives in
/// the service (or the validator it delegates to), never in the controller.
/// </summary>
public sealed record BreadboardResult(
BreadboardOutcome Outcome,
IReadOnlyList<string> Errors,
BreadboardProject? Project)
{
/// <summary>The single wording for "gone or never yours" — read and write paths share it.</summary>
public const string NotFoundMessage = "Project not found";
public static BreadboardResult Succeeded(BreadboardProject? project = null) =>
new(BreadboardOutcome.Success, [], project);
public static BreadboardResult Missing() =>
new(BreadboardOutcome.NotFound, [NotFoundMessage], null);
public static BreadboardResult Invalid(IReadOnlyList<string> errors) =>
new(BreadboardOutcome.Invalid, errors, null);
public static BreadboardResult Failed(string error) =>
new(BreadboardOutcome.Failed, [error], null);
}
@@ -3,6 +3,7 @@ namespace Media.JoshHeaps.Net.Models;
public class MedicalDoctor public class MedicalDoctor
{ {
public long Id { get; set; } public long Id { get; set; }
public long PersonId { get; set; }
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
public string? Specialty { get; set; } public string? Specialty { get; set; }
public string? Phone { get; set; } public string? Phone { get; set; }
@@ -9,3 +9,9 @@ public class MedicalPerson
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; } public DateTime UpdatedAt { get; set; }
} }
public class PersonAccessUser
{
public long Id { get; set; }
public string Username { get; set; } = string.Empty;
}
@@ -4,6 +4,7 @@ public class TimelineEvent
{ {
public string EventType { get; set; } = ""; public string EventType { get; set; } = "";
public long Id { get; set; } public long Id { get; set; }
public long PersonId { get; set; }
public string? Label { get; set; } public string? Label { get; set; }
public string? Detail { get; set; } public string? Detail { get; set; }
public string? SubType { get; set; } public string? SubType { get; set; }
@@ -0,0 +1,11 @@
namespace Media.JoshHeaps.Net.Models;
public class UserThemeOverrides
{
public long Id { get; set; }
public long UserId { get; set; }
public string BaseTheme { get; set; } = "light";
public Dictionary<string, string> ColorOverrides { get; set; } = new();
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}
@@ -0,0 +1,87 @@
@page
@model Media.JoshHeaps.Net.Pages.BlogAdminModel
@{
ViewData["Title"] = "Blog Admin";
Layout = "_Layout";
}
@section Styles {
<link rel="stylesheet" href="~/css/blog-admin.css" asp-append-version="true" />
}
<div class="dashboard-container">
<div class="welcome-section">
<div class="welcome-left">
<a href="/Admin" class="back-button" title="Back to Admin">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="19" y1="12" x2="5" y2="12"></line>
<polyline points="12 19 5 12 12 5"></polyline>
</svg>
</a>
<h1>Blog Admin</h1>
</div>
<div class="quick-actions">
<button id="newPostBtn" class="btn btn-primary">New Post</button>
</div>
</div>
<div id="postsList" class="admin-section">
<div class="section-header">
<h2>Posts</h2>
</div>
<div class="table-container">
<table class="admin-table">
<thead>
<tr>
<th>Title</th>
<th>Slug</th>
<th>Tags</th>
<th>Published</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="postsTableBody"></tbody>
</table>
</div>
</div>
<div id="postEditor" class="admin-section" style="display: none;">
<div class="section-header">
<h2 id="editorTitle">New Post</h2>
</div>
<input type="hidden" id="editPostId" />
<div class="form-group">
<label for="postTitle">Title</label>
<input type="text" id="postTitle" class="form-input" placeholder="Post title" />
</div>
<div class="form-group">
<label for="postSummary">Summary</label>
<input type="text" id="postSummary" class="form-input" placeholder="Brief summary" />
</div>
<div class="form-group">
<label for="postTags">Tags (comma-separated)</label>
<input type="text" id="postTags" class="form-input" placeholder="dev, personal" />
</div>
<div class="form-group">
<label for="postDate">Published Date</label>
<input type="date" id="postDate" class="form-input" />
</div>
<div class="form-group">
<label for="postContent">Markdown Content</label>
<div class="content-toolbar">
<button type="button" id="uploadImageBtn" class="btn btn-secondary btn-sm">Upload Image</button>
<input type="file" id="imageFileInput" accept="image/jpeg,image/png,image/gif,image/webp" style="display: none;" />
<span id="uploadStatus" class="upload-status"></span>
</div>
<textarea id="postContent" class="form-input form-textarea" placeholder="Write your post in markdown..."></textarea>
</div>
<div class="editor-actions">
<button id="savePostBtn" class="btn btn-primary">Save</button>
<button id="cancelEditBtn" class="btn btn-secondary">Cancel</button>
</div>
</div>
</div>
@section Scripts {
<script src="~/js/blog-admin.js" asp-append-version="true"></script>
}
@@ -0,0 +1,19 @@
using Microsoft.AspNetCore.Mvc;
namespace Media.JoshHeaps.Net.Pages;
public class BlogAdminModel(DbExecutor dbExecutor) : AuthenticatedPageModel
{
private readonly DbExecutor _dbExecutor = dbExecutor;
public async Task<IActionResult> OnGetAsync()
{
RequireAuthentication();
LoadUserSession();
var denied = await RequireRole("admin", _dbExecutor);
if (denied != null) return denied;
return Page();
}
}
+176
View File
@@ -0,0 +1,176 @@
@page
@model Media.JoshHeaps.Net.Pages.BreadboardModel
@{
ViewData["Title"] = "Breadboard Simulator";
Layout = "_Layout";
}
@section Styles {
<link rel="stylesheet" href="~/css/breadboard-projects.css" asp-append-version="true" />
}
@* Class names are this page's own (bbp- prefix, styled in breadboard-projects.css).
This app never links Bootstrap's stylesheet, so Bootstrap class names here would imply
styling that does not exist. Colours come from the site.css custom properties, so the
page follows the theme toggle without any theme-specific rules. *@
<div class="bbp-page">
<div class="bbp-header">
<div class="bbp-header-left">
<a href="/Landing" class="bbp-back" title="Back to Home">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="19" y1="12" x2="5" y2="12"></line>
<polyline points="12 19 5 12 12 5"></polyline>
</svg>
</a>
<h1>Breadboard Simulator</h1>
</div>
<a href="/Logout" class="bbp-btn bbp-btn-danger">Logout</a>
</div>
<div id="bb-error" class="bbp-error" role="alert"></div>
<div class="bbp-card">
<h2 class="bbp-card-title">New project</h2>
<form id="bb-create-form" class="bbp-create-form">
<div class="bbp-field">
<label for="bb-new-name">Name</label>
<input type="text" class="bbp-input" id="bb-new-name" maxlength="200" required />
</div>
<div class="bbp-field bbp-field-grow">
<label for="bb-new-description">Description</label>
<input type="text" class="bbp-input" id="bb-new-description" maxlength="2000" />
</div>
<button type="submit" class="bbp-btn bbp-btn-primary">Create</button>
</form>
</div>
@if (Model.Projects.Count == 0)
{
<p class="bbp-empty">No projects yet. Create one above to start wiring.</p>
}
else
{
<div class="bbp-list" id="bb-project-list">
@foreach (var project in Model.Projects)
{
<div class="bbp-item" data-project-id="@project.Id" data-project-name="@project.Name">
<div class="bbp-item-main">
<a class="bbp-item-name" href="/[email protected]">@project.Name</a>
@if (!string.IsNullOrWhiteSpace(project.Description))
{
<div class="bbp-item-description">@project.Description</div>
}
@* Rendered in the viewer's timezone by the module script below, not the server's. *@
<div class="bbp-item-meta bbp-updated" data-updated-utc="@project.UpdatedAt.ToUniversalTime().ToString("O")"></div>
</div>
<div class="bbp-item-actions">
@* An <a>, not a <button data-action>, so the delegated handler below ignores it. *@
<a class="bbp-btn bbp-btn-primary" href="/[email protected]">Open</a>
<button type="button" class="bbp-btn" data-action="rename">Rename</button>
<button type="button" class="bbp-btn bbp-btn-danger" data-action="delete">Delete</button>
</div>
</div>
}
</div>
}
</div>
@section Scripts {
<script type="module">
const projectsUrl = '/api/breadboard/projects';
const errorBox = document.getElementById('bb-error');
function showError(message) {
errorBox.textContent = message;
errorBox.classList.add('is-visible');
}
function clearError() {
errorBox.textContent = '';
errorBox.classList.remove('is-visible');
}
async function send(method, url, body) {
clearError();
let response;
try {
response = await fetch(url, {
method,
credentials: 'same-origin',
headers: body === undefined ? {} : { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body)
});
} catch {
// A silent no-op would read as success, which is the worst outcome for a delete.
showError('Network error - could not reach the server. Nothing was changed.');
return false;
}
if (response.ok) {
return true;
}
let message = `Request failed (${response.status})`;
try {
const payload = await response.json();
if (Array.isArray(payload?.errors) && payload.errors.length > 0) {
message = payload.errors.join(' ');
}
} catch {
// Non-JSON error body; the status-based message stands.
}
showError(message);
return false;
}
for (const cell of document.querySelectorAll('.bbp-updated')) {
const updated = new Date(cell.dataset.updatedUtc);
cell.textContent = `Updated ${updated.toLocaleString()}`;
}
document.getElementById('bb-create-form').addEventListener('submit', async (event) => {
event.preventDefault();
const name = document.getElementById('bb-new-name').value.trim();
const description = document.getElementById('bb-new-description').value.trim();
if (name.length === 0) {
showError('Name is required');
return;
}
if (await send('POST', projectsUrl, { name, description: description || null })) {
window.location.reload();
}
});
document.getElementById('bb-project-list')?.addEventListener('click', async (event) => {
const button = event.target.closest('button[data-action]');
if (button === null) {
return;
}
const row = button.closest('[data-project-id]');
const id = row.dataset.projectId;
const currentName = row.dataset.projectName;
if (button.dataset.action === 'rename') {
const name = window.prompt('New name', currentName);
if (name === null || name.trim().length === 0) {
return;
}
if (await send('PUT', `${projectsUrl}/${id}`, { name: name.trim() })) {
window.location.reload();
}
return;
}
if (window.confirm(`Delete "${currentName}"? This cannot be undone.`)) {
if (await send('DELETE', `${projectsUrl}/${id}`)) {
window.location.reload();
}
}
});
</script>
}
@@ -0,0 +1,26 @@
using Media.JoshHeaps.Net.Models;
using Media.JoshHeaps.Net.Services;
using Microsoft.AspNetCore.Mvc;
namespace Media.JoshHeaps.Net.Pages;
public class BreadboardModel(BreadboardService breadboardService) : AuthenticatedPageModel
{
public List<BreadboardProjectSummary> Projects { get; private set; } = [];
public async Task<IActionResult> OnGetAsync()
{
RequireAuthentication();
LoadUserSession();
// RequireAuthentication only queues a redirect, so bail out explicitly rather than
// rendering the page against a zero user id.
if (UserId == 0)
{
return Redirect("/Login");
}
Projects = await breadboardService.GetProjectsAsync(UserId);
return Page();
}
}
@@ -0,0 +1,24 @@
@page
@model Media.JoshHeaps.Net.Pages.BreadboardEditorModel
@{
ViewData["Title"] = $"Breadboard - {Model.ProjectName}";
Layout = "_Layout";
}
@section Styles {
<link rel="stylesheet" href="~/css/breadboard.css" asp-append-version="true" />
}
@* Shell only. Everything inside #breadboard-editor is built by the editor module, so the
component palette can grow without a Razor edit. Contract agreed with frontend-impl:
the root id and the three data-attributes below are the entire server-to-editor surface. *@
<div class="bb-fullbleed">
<div id="breadboard-editor"
data-project-id="@Model.ProjectId"
data-project-name="@Model.ProjectName"
data-api-base="/api/breadboard"></div>
</div>
@section Scripts {
<script type="module" src="~/js/breadboard/editor/main.js"></script>
}
@@ -0,0 +1,34 @@
using Media.JoshHeaps.Net.Services;
using Microsoft.AspNetCore.Mvc;
namespace Media.JoshHeaps.Net.Pages;
public class BreadboardEditorModel(BreadboardService breadboardService) : AuthenticatedPageModel
{
public long ProjectId { get; private set; }
public string ProjectName { get; private set; } = string.Empty;
public async Task<IActionResult> OnGetAsync([FromQuery] long projectId)
{
RequireAuthentication();
LoadUserSession();
if (UserId == 0)
{
return Redirect("/Login");
}
// Ownership check lives in SQL, so someone else's project is simply not found. The
// summary lookup deliberately skips the circuit — the editor module fetches the
// document itself, and pulling it here would parse a multi-megabyte payload to throw away.
var project = await breadboardService.GetProjectSummaryAsync(projectId, UserId);
if (project == null)
{
return NotFound();
}
ProjectId = project.Id;
ProjectName = project.Name;
return Page();
}
}
+1
View File
@@ -21,6 +21,7 @@
</a> </a>
<h1>Welcome back, @Model.Dashboard?.Username!</h1> <h1>Welcome back, @Model.Dashboard?.Username!</h1>
</div> </div>
<div class="quick-actions"> <div class="quick-actions">
@if (Model.Dashboard?.EmailVerified == false) @if (Model.Dashboard?.EmailVerified == false)
{ {
+54
View File
@@ -93,6 +93,35 @@
</div> </div>
</a> </a>
<a href="/Breadboard" class="landing-card">
<div class="card-content">
<div class="card-icon-wrapper">
<div class="card-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="4" y="4" width="16" height="16" rx="2" ry="2"></rect>
<line x1="9" y1="4" x2="9" y2="20"></line>
<line x1="15" y1="4" x2="15" y2="20"></line>
<line x1="4" y1="9" x2="20" y2="9"></line>
<line x1="4" y1="15" x2="20" y2="15"></line>
</svg>
</div>
</div>
<div class="card-text">
<h2>Breadboard Simulator</h2>
<p class="card-description">Build and simulate logic circuits on a virtual solderless breadboard</p>
</div>
</div>
<div class="card-footer">
<span class="card-link-text">Open workspace</span>
<div class="card-arrow">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="5" y1="12" x2="19" y2="12"></line>
<polyline points="12 5 19 12 12 19"></polyline>
</svg>
</div>
</div>
</a>
@if (Model.IsAdmin) @if (Model.IsAdmin)
{ {
<a href="/Admin" class="landing-card"> <a href="/Admin" class="landing-card">
@@ -120,6 +149,31 @@
</div> </div>
</a> </a>
<a href="/BlogAdmin" class="landing-card">
<div class="card-content">
<div class="card-icon-wrapper">
<div class="card-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h9"></path>
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path>
</svg>
</div>
</div>
<div class="card-text">
<h2>Blog</h2>
<p class="card-description">Create and manage blog posts for your website</p>
</div>
</div>
<div class="card-footer">
<span class="card-link-text">Open workspace</span>
<div class="card-arrow">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="5" y1="12" x2="19" y2="12"></line>
<polyline points="12 5 19 12 12 19"></polyline>
</svg>
</div>
</div>
</a>
} }
@if (Model.HasMedicalRole) @if (Model.HasMedicalRole)
+11 -4
View File
@@ -43,6 +43,10 @@
<form id="loginForm" method="post"> <form id="loginForm" method="post">
@Html.AntiForgeryToken() @Html.AntiForgeryToken()
@if (!string.IsNullOrEmpty(Model.ReturnUrl))
{
<input type="hidden" name="ReturnUrl" value="@Model.ReturnUrl" />
}
<div class="form-group"> <div class="form-group">
<label for="email" class="form-label">Email or Username</label> <label for="email" class="form-label">Email or Username</label>
<input type="text" class="form-control" id="email" name="email" <input type="text" class="form-control" id="email" name="email"
@@ -60,10 +64,13 @@
<div class="invalid-feedback"></div> <div class="invalid-feedback"></div>
</div> </div>
<div class="checkbox-wrapper"> <div class="form-options">
<input type="checkbox" id="rememberMe" name="rememberMe" <div class="checkbox-wrapper">
@(Model.RememberMe ? "checked" : "") /> <input type="checkbox" id="rememberMe" name="rememberMe"
<label for="rememberMe">Remember me</label> @(Model.RememberMe ? "checked" : "") />
<label for="rememberMe">Remember me</label>
</div>
<a href="/LoginHelp" class="forgot-password-link">Forgot password?</a>
</div> </div>
<button type="submit" class="btn-primary">Sign In</button> <button type="submit" class="btn-primary">Sign In</button>
+15 -3
View File
@@ -16,17 +16,20 @@ public class LoginModel(AuthService authService) : PageModel
[BindProperty] [BindProperty]
public bool RememberMe { get; set; } public bool RememberMe { get; set; }
[BindProperty(SupportsGet = true)]
public string? ReturnUrl { get; set; }
public string? ErrorMessage { get; set; } public string? ErrorMessage { get; set; }
public string? SuccessMessage { get; set; } public string? SuccessMessage { get; set; }
public string? WarningMessage { get; set; } public string? WarningMessage { get; set; }
public void OnGet([FromQuery] string? registered, [FromQuery] string? verified) public void OnGet([FromQuery] string? registered, [FromQuery] string? verified, [FromQuery] string? reset)
{ {
// Check if user is already logged in // Check if user is already logged in
var userId = HttpContext.Session.GetString("UserId"); var userId = HttpContext.Session.GetString("UserId");
if (!string.IsNullOrEmpty(userId)) if (!string.IsNullOrEmpty(userId))
{ {
Response.Redirect("/Landing"); Response.Redirect(SafeReturnUrl() ?? "/Landing");
return; return;
} }
@@ -41,6 +44,12 @@ public class LoginModel(AuthService authService) : PageModel
{ {
SuccessMessage = "Email verified! You can now sign in."; SuccessMessage = "Email verified! You can now sign in.";
} }
// Show success message if password was just reset
if (reset == "true")
{
SuccessMessage = "Your password has been reset. You can now sign in with your new password.";
}
} }
public async Task<IActionResult> OnPostAsync() public async Task<IActionResult> OnPostAsync()
@@ -84,6 +93,9 @@ public class LoginModel(AuthService authService) : PageModel
Response.Cookies.Append("RememberMe", userInfo.Id.ToString(), cookieOptions); Response.Cookies.Append("RememberMe", userInfo.Id.ToString(), cookieOptions);
} }
return Redirect("/Landing"); return Redirect(SafeReturnUrl() ?? "/Landing");
} }
private string? SafeReturnUrl() =>
!string.IsNullOrWhiteSpace(ReturnUrl) && Url.IsLocalUrl(ReturnUrl) ? ReturnUrl : null;
} }
+102
View File
@@ -0,0 +1,102 @@
@page
@model Media.JoshHeaps.Net.Pages.LoginHelpModel
@{
ViewData["Title"] = "Login Help";
Layout = "_Layout";
}
@section Styles {
<link rel="stylesheet" href="~/css/auth.css" asp-append-version="true" />
}
@section Scripts {
<script src="~/js/auth.js" asp-append-version="true"></script>
}
<div class="auth-container">
<div class="auth-card">
@if (Model.ShowResetForm)
{
<div class="auth-header">
<h1>Reset Password</h1>
<p>Enter your new password below</p>
</div>
@if (!string.IsNullOrEmpty(Model.ErrorMessage))
{
<div class="alert alert-danger">
@Model.ErrorMessage
</div>
}
<form id="resetPasswordForm" method="post" asp-page-handler="ResetPassword">
@Html.AntiForgeryToken()
<input type="hidden" name="Token" value="@Model.Token" />
<div class="form-group">
<label for="newPassword" class="form-label">New Password</label>
<div class="password-wrapper">
<input type="password" class="form-control" id="newPassword" name="NewPassword"
autocomplete="new-password" required minlength="8" />
<button type="button" class="password-toggle">Show</button>
</div>
<div class="invalid-feedback"></div>
<div class="password-strength">
<div class="password-strength-bar"></div>
</div>
<div class="password-strength-text"></div>
</div>
<div class="form-group">
<label for="confirmPassword" class="form-label">Confirm Password</label>
<div class="password-wrapper">
<input type="password" class="form-control" id="confirmPassword" name="ConfirmPassword"
autocomplete="new-password" required minlength="8" />
<button type="button" class="password-toggle">Show</button>
</div>
<div class="invalid-feedback"></div>
</div>
<button type="submit" class="btn-primary">Reset Password</button>
</form>
}
else
{
<div class="auth-header">
<h1>Forgot Password</h1>
<p>Enter your email to receive a reset link</p>
</div>
@if (!string.IsNullOrEmpty(Model.ErrorMessage))
{
<div class="alert alert-danger">
@Model.ErrorMessage
</div>
}
@if (!string.IsNullOrEmpty(Model.SuccessMessage))
{
<div class="alert alert-success">
@Model.SuccessMessage
</div>
}
<form id="requestResetForm" method="post" asp-page-handler="RequestReset">
@Html.AntiForgeryToken()
<div class="form-group">
<label for="email" class="form-label">Email Address</label>
<input type="email" class="form-control" id="email" name="Email"
value="@Model.Email" autocomplete="email" required />
<div class="invalid-feedback"></div>
</div>
<button type="submit" class="btn-primary">Send Reset Link</button>
</form>
}
<div class="auth-footer">
<p>Remember your password? <a href="/Login">Sign in</a></p>
</div>
</div>
</div>
@@ -0,0 +1,111 @@
using Media.JoshHeaps.Net.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace Media.JoshHeaps.Net.Pages;
public class LoginHelpModel(AuthService authService, EmailService emailService, ILogger<LoginHelpModel> logger) : PageModel
{
[BindProperty]
public string Email { get; set; } = string.Empty;
[BindProperty]
public string Token { get; set; } = string.Empty;
[BindProperty]
public string NewPassword { get; set; } = string.Empty;
[BindProperty]
public string ConfirmPassword { get; set; } = string.Empty;
public string? ErrorMessage { get; set; }
public string? SuccessMessage { get; set; }
public bool ShowResetForm { get; set; }
public async Task<IActionResult> OnGetAsync([FromQuery] string? token)
{
// Redirect if already logged in
var userId = HttpContext.Session.GetString("UserId");
if (!string.IsNullOrEmpty(userId))
return Redirect("/Landing");
if (!string.IsNullOrEmpty(token))
{
var (valid, error) = await authService.ValidatePasswordResetTokenAsync(token);
if (valid)
{
ShowResetForm = true;
Token = token;
}
else
{
ErrorMessage = error;
}
}
return Page();
}
public async Task<IActionResult> OnPostRequestResetAsync()
{
// Redirect if already logged in
var userId = HttpContext.Session.GetString("UserId");
if (!string.IsNullOrEmpty(userId))
return Redirect("/Landing");
if (string.IsNullOrWhiteSpace(Email))
{
ErrorMessage = "Please enter your email address.";
return Page();
}
var (success, error, token, username) = await authService.RequestPasswordResetAsync(Email.Trim());
if (!success)
{
logger.LogError("Password reset request failed for {Email}: {Error}", Email, error);
}
// Send email if we got a token back (user exists and is eligible)
if (token != null)
{
await emailService.SendPasswordResetEmailAsync(Email.Trim(), username ?? Email.Split('@')[0], token);
}
// Always show the same message regardless of whether the email exists
SuccessMessage = "If an account exists with that email, you will receive a password reset link shortly.";
return Page();
}
public async Task<IActionResult> OnPostResetPasswordAsync()
{
// Redirect if already logged in
var userId = HttpContext.Session.GetString("UserId");
if (!string.IsNullOrEmpty(userId))
return Redirect("/Landing");
if (string.IsNullOrWhiteSpace(NewPassword) || NewPassword.Length < 8)
{
ErrorMessage = "Password must be at least 8 characters.";
ShowResetForm = true;
return Page();
}
if (NewPassword != ConfirmPassword)
{
ErrorMessage = "Passwords do not match.";
ShowResetForm = true;
return Page();
}
var (success, error) = await authService.ResetPasswordAsync(Token, NewPassword);
if (!success)
{
ErrorMessage = error;
return Page();
}
return Redirect("/Login?reset=true");
}
}
@@ -285,6 +285,23 @@
</div> </div>
</div> </div>
<!-- Share Access Modal -->
<div class="doc-viewer-overlay" id="shareAccessOverlay" style="display:none" onclick="medDocsCloseShareModal(event)">
<div class="doc-viewer-modal" style="max-width:420px;max-height:400px;" onclick="event.stopPropagation()">
<div class="doc-viewer-header">
<span class="doc-viewer-title">Share Patient Access</span>
<button class="doc-viewer-close" onclick="medDocsCloseShareModal()" title="Close">&times;</button>
</div>
<div class="doc-viewer-body" style="padding:1rem;overflow-y:auto;">
<div id="shareAccessList" style="margin-bottom:1rem;"></div>
<div style="display:flex;gap:0.5rem;">
<input type="text" id="shareUsername" placeholder="Username..." class="form-input" style="flex:1;" />
<button class="btn btn-primary btn-sm" onclick="medDocsGrantAccess()">Grant</button>
</div>
</div>
</div>
</div>
@section Scripts { @section Scripts {
<script src="~/js/medical-docs/state.js" asp-append-version="true"></script> <script src="~/js/medical-docs/state.js" asp-append-version="true"></script>
<script src="~/js/medical-docs/tabs.js" asp-append-version="true"></script> <script src="~/js/medical-docs/tabs.js" asp-append-version="true"></script>
+11 -127
View File
@@ -7,133 +7,8 @@
@section Styles { @section Styles {
<link rel="stylesheet" href="~/css/Home/page.css" asp-append-version="true" /> <link rel="stylesheet" href="~/css/Home/page.css" asp-append-version="true" />
<style> <link rel="stylesheet" href="~/css/profile.css" asp-append-version="true" />
.profile-container { <link rel="stylesheet" href="~/css/theme-customizer.css" asp-append-version="true" />
max-width: 800px;
margin: 40px auto;
padding: 0 20px;
}
.profile-section {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 8px;
padding: 24px;
margin-bottom: 20px;
}
.profile-section h2 {
margin: 0 0 20px 0;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
border-bottom: 1px solid var(--border-primary);
padding-bottom: 12px;
}
.profile-field {
margin-bottom: 16px;
}
.profile-field label {
display: block;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 4px;
font-size: 14px;
}
.profile-field-value {
color: var(--text-primary);
font-size: 15px;
}
.theme-toggle-container {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
}
.theme-toggle-label {
display: flex;
flex-direction: column;
}
.theme-toggle-label strong {
color: var(--text-primary);
font-size: 15px;
margin-bottom: 4px;
}
.theme-toggle-label span {
color: var(--text-secondary);
font-size: 13px;
}
.toggle-switch {
position: relative;
display: inline-block;
width: 50px;
height: 26px;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--bg-tertiary);
border: 1px solid var(--border-primary);
transition: 0.3s;
border-radius: 34px;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: var(--text-secondary);
transition: 0.3s;
border-radius: 50%;
}
input:checked + .toggle-slider {
background-color: var(--accent-primary);
border-color: var(--accent-primary);
}
input:checked + .toggle-slider:before {
transform: translateX(24px);
background-color: var(--bg-primary);
}
.back-link {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--accent-primary);
text-decoration: none;
font-size: 14px;
margin-bottom: 20px;
transition: color 0.2s ease;
}
.back-link:hover {
color: var(--accent-hover);
}
</style>
} }
<div class="profile-container"> <div class="profile-container">
@@ -176,5 +51,14 @@
<span class="toggle-slider"></span> <span class="toggle-slider"></span>
</label> </label>
</div> </div>
<div class="theme-toggle-container">
<div class="theme-toggle-label">
<strong>Custom Colors</strong>
<span>Personalize individual theme colors</span>
</div>
<button class="customize-btn" onclick="openThemeCustomizer()">Customize</button>
</div>
</div> </div>
</div> </div>
<script src="~/js/theme-customizer.js" asp-append-version="true"></script>
@@ -0,0 +1,5 @@
@page "/sso/authorize"
@model Media.JoshHeaps.Net.Pages.Sso.AuthorizeModel
@{
Layout = null;
}
@@ -0,0 +1,59 @@
using System.Security.Cryptography;
using System.Text;
using Media.JoshHeaps.Net.Services;
using Microsoft.AspNetCore.Mvc;
namespace Media.JoshHeaps.Net.Pages.Sso;
public class AuthorizeModel(DbExecutor db, IConfiguration config, ILogger<AuthorizeModel> logger) : AuthenticatedPageModel
{
public async Task<IActionResult> OnGetAsync(
[FromQuery(Name = "client_id")] string? clientId,
[FromQuery(Name = "redirect_uri")] string? redirectUri,
[FromQuery] string? state)
{
if (string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(redirectUri) || string.IsNullOrWhiteSpace(state))
{
return BadRequest("client_id, redirect_uri, and state are required");
}
var client = SsoClientRegistry.Find(config, clientId);
if (client == null) return BadRequest("unknown client_id");
if (!client.AllowsRedirectUri(redirectUri)) return BadRequest("redirect_uri is not registered for this client");
if (!IsAuthenticated())
{
var original = $"/sso/authorize?client_id={Uri.EscapeDataString(clientId)}&redirect_uri={Uri.EscapeDataString(redirectUri)}&state={Uri.EscapeDataString(state)}";
return Redirect($"/Login?ReturnUrl={Uri.EscapeDataString(original)}");
}
LoadUserSession();
var code = GenerateCode();
var codeHash = HashCode(code);
var lifetime = int.TryParse(config["Sso:CodeLifetimeSeconds"], out var s) ? s : 60;
var expiresAt = DateTimeOffset.UtcNow.AddSeconds(lifetime);
await db.ExecuteNonQueryAsync(
@"INSERT INTO app.sso_authorization_codes (code_hash, client_id, user_id, redirect_uri, expires_at)
VALUES (@codeHash, @clientId, @userId, @redirectUri, @expiresAt)",
new { codeHash, clientId, userId = UserId, redirectUri, expiresAt });
logger.LogInformation("SSO code issued for user {UserId} to client {ClientId}", UserId, clientId);
var separator = redirectUri.Contains('?') ? '&' : '?';
return Redirect($"{redirectUri}{separator}code={Uri.EscapeDataString(code)}&state={Uri.EscapeDataString(state)}");
}
private static string GenerateCode()
{
var bytes = RandomNumberGenerator.GetBytes(32);
return Convert.ToBase64String(bytes).Replace("+", "-").Replace("/", "_").TrimEnd('=');
}
private static string HashCode(string code)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(code));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
}
+5
View File
@@ -19,6 +19,11 @@ builder.Services.AddScoped<FolderService>();
builder.Services.AddScoped<GraphService>(); builder.Services.AddScoped<GraphService>();
builder.Services.AddScoped<MedicalDocsService>(); builder.Services.AddScoped<MedicalDocsService>();
builder.Services.AddSingleton<MedicalAiService>(); builder.Services.AddSingleton<MedicalAiService>();
builder.Services.AddScoped<ThemeService>();
builder.Services.AddScoped<BlogService>();
builder.Services.AddScoped<BreadboardValidator>();
builder.Services.AddScoped<BreadboardService>();
builder.Services.AddHttpClient();
// Add session support // Add session support
builder.Services.AddDistributedMemoryCache(); builder.Services.AddDistributedMemoryCache();
+171
View File
@@ -1,3 +1,5 @@
using System.Security.Cryptography;
using System.Text;
using Media.JoshHeaps.Net; using Media.JoshHeaps.Net;
using Media.JoshHeaps.Net.Models; using Media.JoshHeaps.Net.Models;
@@ -302,4 +304,173 @@ public class AuthService(DbExecutor db)
new { userId, lastLogin = DateTime.UtcNow } new { userId, lastLogin = DateTime.UtcNow }
); );
} }
public static string GenerateSecureToken()
{
var bytes = RandomNumberGenerator.GetBytes(32);
return Convert.ToBase64String(bytes)
.Replace("+", "-")
.Replace("/", "_")
.TrimEnd('=');
}
public static string HashToken(string token)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(token));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
public async Task<(bool Success, string? Error, string? Token, string? Username)> RequestPasswordResetAsync(string email)
{
try
{
var userRow = await db.ExecuteReaderAsync(
"SELECT id, username, is_active, locked_until FROM app.users WHERE email = @email",
reader => new
{
UserId = reader.GetInt64(0),
Username = reader.GetString(1),
IsActive = reader.GetBoolean(2),
LockedUntil = reader.IsDBNull(3) ? (DateTime?)null : reader.GetDateTime(3)
},
new { email }
);
if (userRow == null)
{
// Artificial delay to prevent timing-based email enumeration
await Task.Delay(Random.Shared.Next(100, 300));
return (true, null, null, null);
}
// Silently succeed for inactive/locked accounts (don't reveal state)
if (!userRow.IsActive ||
(userRow.LockedUntil.HasValue && userRow.LockedUntil.Value > DateTime.UtcNow))
{
return (true, null, null, null);
}
// Rate limit: max 3 requests per hour
var recentCount = await db.ExecuteAsync<long>(
@"SELECT COUNT(*) FROM app.password_reset_tokens
WHERE user_id = @userId AND created_at > @cutoff",
new { userId = userRow.UserId, cutoff = DateTimeOffset.UtcNow.AddHours(-1) }
);
if (recentCount >= 3)
{
return (true, null, null, null);
}
// Invalidate all existing unused tokens for this user
await db.ExecuteNonQueryAsync(
@"UPDATE app.password_reset_tokens
SET used_at = @now
WHERE user_id = @userId AND used_at IS NULL",
new { userId = userRow.UserId, now = DateTimeOffset.UtcNow }
);
// Generate and store new token
var token = GenerateSecureToken();
var tokenHash = HashToken(token);
var expiresAt = DateTimeOffset.UtcNow.AddHours(1);
await db.ExecuteNonQueryAsync(
@"INSERT INTO app.password_reset_tokens (user_id, token_hash, expires_at)
VALUES (@userId, @tokenHash, @expiresAt)",
new { userId = userRow.UserId, tokenHash, expiresAt }
);
return (true, null, token, userRow.Username);
}
catch (Exception ex)
{
return (false, $"Password reset request failed: {ex.Message}", null, null);
}
}
public async Task<(bool Valid, string? Error)> ValidatePasswordResetTokenAsync(string token)
{
try
{
var tokenHash = HashToken(token);
var tokenRow = await db.ExecuteReaderAsync(
@"SELECT expires_at, used_at FROM app.password_reset_tokens
WHERE token_hash = @tokenHash",
reader => new
{
ExpiresAt = reader.GetFieldValue<DateTimeOffset>(0),
UsedAt = reader.IsDBNull(1) ? (DateTimeOffset?)null : reader.GetFieldValue<DateTimeOffset>(1)
},
new { tokenHash }
);
if (tokenRow == null)
return (false, "Invalid or expired reset link. Please request a new one.");
if (tokenRow.UsedAt.HasValue)
return (false, "This reset link has already been used. Please request a new one.");
if (tokenRow.ExpiresAt < DateTimeOffset.UtcNow)
return (false, "This reset link has expired. Please request a new one.");
return (true, null);
}
catch (Exception ex)
{
return (false, $"Token validation failed: {ex.Message}");
}
}
public async Task<(bool Success, string? Error)> ResetPasswordAsync(string token, string newPassword)
{
try
{
var tokenHash = HashToken(token);
var tokenRow = await db.ExecuteReaderAsync(
@"SELECT id, user_id, expires_at, used_at FROM app.password_reset_tokens
WHERE token_hash = @tokenHash",
reader => new
{
Id = reader.GetInt64(0),
UserId = reader.GetInt64(1),
ExpiresAt = reader.GetFieldValue<DateTimeOffset>(2),
UsedAt = reader.IsDBNull(3) ? (DateTimeOffset?)null : reader.GetFieldValue<DateTimeOffset>(3)
},
new { tokenHash }
);
if (tokenRow == null)
return (false, "Invalid or expired reset link. Please request a new one.");
if (tokenRow.UsedAt.HasValue)
return (false, "This reset link has already been used. Please request a new one.");
if (tokenRow.ExpiresAt < DateTimeOffset.UtcNow)
return (false, "This reset link has expired. Please request a new one.");
// Hash new password and update user
var passwordHash = HashPassword(newPassword);
await db.ExecuteNonQueryAsync(
@"UPDATE app.users
SET password_hash = @passwordHash, failed_login_attempts = 0, locked_until = NULL
WHERE id = @userId",
new { userId = tokenRow.UserId, passwordHash }
);
// Mark token as used
await db.ExecuteNonQueryAsync(
"UPDATE app.password_reset_tokens SET used_at = @now WHERE id = @tokenId",
new { tokenId = tokenRow.Id, now = DateTimeOffset.UtcNow }
);
return (true, null);
}
catch (Exception ex)
{
return (false, $"Password reset failed: {ex.Message}");
}
}
} }
+258
View File
@@ -0,0 +1,258 @@
using System.Text.RegularExpressions;
using Markdig;
using Media.JoshHeaps.Net.Models;
namespace Media.JoshHeaps.Net.Services;
public partial class BlogService(DbExecutor db, IWebHostEnvironment environment, ILogger<BlogService> logger)
{
private static readonly HashSet<string> AllowedMimeTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"];
private const long MaxImageSize = 10 * 1024 * 1024;
private static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder()
.UseAdvancedExtensions()
.Build();
public async Task<List<BlogPost>> GetAllPostsAsync()
{
try
{
return await db.ExecuteListReaderAsync(
@"SELECT id, slug, title, summary, markdown_content, html_content, tags, author_id, published_date, created_at, updated_at
FROM app.blog_posts
ORDER BY published_date DESC",
MapBlogPost);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get all blog posts");
return [];
}
}
public async Task<BlogPost?> GetPostByIdAsync(long id)
{
try
{
return await db.ExecuteReaderAsync(
@"SELECT id, slug, title, summary, markdown_content, html_content, tags, author_id, published_date, created_at, updated_at
FROM app.blog_posts
WHERE id = @Id",
MapBlogPost,
new { Id = id });
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get blog post by id {Id}", id);
return null;
}
}
public async Task<BlogPost?> GetPostBySlugAsync(string slug)
{
try
{
return await db.ExecuteReaderAsync(
@"SELECT id, slug, title, summary, markdown_content, html_content, tags, author_id, published_date, created_at, updated_at
FROM app.blog_posts
WHERE slug = @Slug",
MapBlogPost,
new { Slug = slug });
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get blog post by slug {Slug}", slug);
return null;
}
}
public async Task<List<BlogPost>> GetPostsByTagAsync(string tag)
{
try
{
return await db.ExecuteListReaderAsync(
@"SELECT id, slug, title, summary, markdown_content, html_content, tags, author_id, published_date, created_at, updated_at
FROM app.blog_posts
WHERE @Tag = ANY(tags)
ORDER BY published_date DESC",
MapBlogPost,
new { Tag = tag });
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get blog posts by tag {Tag}", tag);
return [];
}
}
public async Task<BlogPost?> CreatePostAsync(string title, string summary, string markdownContent, List<string> tags, long authorId, DateTime publishedDate)
{
try
{
var slug = GenerateSlug(title);
var htmlContent = Markdown.ToHtml(markdownContent, Pipeline);
var now = DateTime.UtcNow;
return await db.ExecuteReaderAsync(
@"INSERT INTO app.blog_posts (slug, title, summary, markdown_content, html_content, tags, author_id, published_date, created_at, updated_at)
VALUES (@Slug, @Title, @Summary, @MarkdownContent, @HtmlContent, @Tags, @AuthorId, @PublishedDate, @CreatedAt, @UpdatedAt)
RETURNING id, slug, title, summary, markdown_content, html_content, tags, author_id, published_date, created_at, updated_at",
MapBlogPost,
new
{
Slug = slug,
Title = title,
Summary = summary,
MarkdownContent = markdownContent,
HtmlContent = htmlContent,
Tags = tags.ToArray(),
AuthorId = authorId,
PublishedDate = publishedDate,
CreatedAt = now,
UpdatedAt = now
});
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to create blog post '{Title}'", title);
return null;
}
}
public async Task<BlogPost?> UpdatePostAsync(long id, string title, string summary, string markdownContent, List<string> tags, DateTime publishedDate)
{
try
{
var slug = GenerateSlug(title);
var htmlContent = Markdown.ToHtml(markdownContent, Pipeline);
return await db.ExecuteReaderAsync(
@"UPDATE app.blog_posts
SET slug = @Slug, title = @Title, summary = @Summary, markdown_content = @MarkdownContent,
html_content = @HtmlContent, tags = @Tags, published_date = @PublishedDate, updated_at = @UpdatedAt
WHERE id = @Id
RETURNING id, slug, title, summary, markdown_content, html_content, tags, author_id, published_date, created_at, updated_at",
MapBlogPost,
new
{
Id = id,
Slug = slug,
Title = title,
Summary = summary,
MarkdownContent = markdownContent,
HtmlContent = htmlContent,
Tags = tags.ToArray(),
PublishedDate = publishedDate,
UpdatedAt = DateTime.UtcNow
});
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to update blog post {Id}", id);
return null;
}
}
public async Task<bool> DeletePostAsync(long id)
{
try
{
var rows = await db.ExecuteNonQueryAsync(
"DELETE FROM app.blog_posts WHERE id = @Id",
new { Id = id });
return rows > 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to delete blog post {Id}", id);
return false;
}
}
public async Task<(string? url, string? error)> SaveImageAsync(IFormFile file)
{
if (!AllowedMimeTypes.Contains(file.ContentType))
return (null, "Only JPEG, PNG, GIF, and WEBP images are allowed");
if (file.Length > MaxImageSize)
return (null, "Image must be under 10MB");
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (string.IsNullOrEmpty(ext)) ext = ".jpg";
var fileName = $"{Guid.NewGuid()}{ext}";
var folder = Path.Combine(environment.ContentRootPath, "App_Data", "blog");
Directory.CreateDirectory(folder);
var filePath = Path.Combine(folder, fileName);
try
{
await using var stream = new FileStream(filePath, FileMode.Create);
await file.CopyToAsync(stream);
return ($"/api/blog/images/{fileName}", null);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to save blog image");
if (File.Exists(filePath)) File.Delete(filePath);
return (null, "Failed to save image");
}
}
public (string? filePath, string? mimeType) GetImagePath(string fileName)
{
if (fileName.Contains("..") || fileName.Contains('/') || fileName.Contains('\\'))
return (null, null);
var filePath = Path.Combine(environment.ContentRootPath, "App_Data", "blog", fileName);
if (!File.Exists(filePath))
return (null, null);
var ext = Path.GetExtension(fileName).ToLowerInvariant();
var mimeType = ext switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
_ => "application/octet-stream"
};
return (filePath, mimeType);
}
private static BlogPost MapBlogPost(Npgsql.NpgsqlDataReader reader)
{
return new BlogPost
{
Id = reader.GetInt64(0),
Slug = reader.GetString(1),
Title = reader.GetString(2),
Summary = reader.GetString(3),
MarkdownContent = reader.GetString(4),
HtmlContent = reader.GetString(5),
Tags = reader.GetFieldValue<string[]>(6).ToList(),
AuthorId = reader.GetInt64(7),
PublishedDate = reader.GetDateTime(8),
CreatedAt = reader.GetDateTime(9),
UpdatedAt = reader.GetDateTime(10)
};
}
private static string GenerateSlug(string title)
{
var slug = title.ToLowerInvariant();
slug = SlugInvalidChars().Replace(slug, "");
slug = SlugWhitespace().Replace(slug, "-");
slug = SlugMultipleDashes().Replace(slug, "-");
return slug.Trim('-');
}
[GeneratedRegex(@"[^a-z0-9\s-]")]
private static partial Regex SlugInvalidChars();
[GeneratedRegex(@"\s+")]
private static partial Regex SlugWhitespace();
[GeneratedRegex(@"-{2,}")]
private static partial Regex SlugMultipleDashes();
}
@@ -0,0 +1,293 @@
using System.Text.Json.Nodes;
using Media.JoshHeaps.Net.Models;
using Npgsql;
namespace Media.JoshHeaps.Net.Services;
/// <summary>
/// Data access and write rules for breadboard projects. The circuit document is opaque
/// here: it travels as JSON text, is checked by <see cref="BreadboardValidator"/> before
/// it ever reaches the database, and is only parsed on the way out so the API can emit it
/// as a JSON object. Ownership is enforced in SQL — every statement is scoped by user_id,
/// so a project belonging to someone else is indistinguishable from one that never existed.
/// </summary>
public class BreadboardService(DbExecutor db, BreadboardValidator validator, ILogger<BreadboardService> logger)
{
public const int MaxNameLength = 200;
public const int MaxDescriptionLength = 2000;
public const int MaxProjectsPerUser = 200;
private const string EmptyCircuitJson = """{"version":1,"boards":[],"components":[],"wires":[]}""";
public async Task<List<BreadboardProjectSummary>> GetProjectsAsync(long userId)
{
try
{
var query = @"
SELECT id, name, description, created_at, updated_at
FROM app.breadboard_projects
WHERE user_id = @userId
ORDER BY updated_at DESC";
return await db.ExecuteListReaderAsync(query, MapSummary, new { userId });
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to list breadboard projects for user {UserId}", userId);
return [];
}
}
/// <summary>
/// Ownership check plus display fields, without dragging the circuit document along.
/// Pages that only need to know "is this mine, and what is it called" use this so the
/// document is fetched exactly once, by the editor module over the API.
/// </summary>
public async Task<BreadboardProjectSummary?> GetProjectSummaryAsync(long projectId, long userId)
{
var query = @"
SELECT id, name, description, created_at, updated_at
FROM app.breadboard_projects
WHERE id = @projectId AND user_id = @userId";
return await db.ExecuteReaderAsync(query, MapSummary, new { projectId, userId });
}
/// <summary>
/// Returns null only when the project does not exist or is not this user's. Database
/// failures deliberately propagate — a caller must never turn an outage into a 404.
/// </summary>
public async Task<BreadboardProject?> GetProjectAsync(long projectId, long userId)
{
var query = @"
SELECT id, name, description, circuit::text, created_at, updated_at
FROM app.breadboard_projects
WHERE id = @projectId AND user_id = @userId";
return await db.ExecuteReaderAsync(query, MapProject, new { projectId, userId });
}
public async Task<BreadboardResult> CreateProjectAsync(long userId, string? name, string? description)
{
var errors = new List<string>();
var trimmedName = ValidateName(name, errors);
var trimmedDescription = ValidateDescription(description, errors);
if (errors.Count > 0)
{
return BreadboardResult.Invalid(errors);
}
// Even the starter document goes through the validator — there is no trusted path by
// which a circuit reaches the database unchecked. It is server-authored though, so a
// rejection means the seed and the validator have drifted: that is our bug, not the
// caller's, and it must not surface as a 400 blaming their input.
var circuitCheck = validator.Validate(EmptyCircuitJson);
if (!circuitCheck.IsValid)
{
logger.LogError(
"Seed breadboard circuit document was rejected by the validator: {Errors}",
string.Join(", ", circuitCheck.Errors));
return BreadboardResult.Failed("Failed to create project");
}
try
{
// An authenticated user can otherwise grow the table without bound, 2 MB at a time.
var projectCount = await db.ExecuteAsync<long>(
"SELECT COUNT(*) FROM app.breadboard_projects WHERE user_id = @userId",
new { userId });
if (projectCount >= MaxProjectsPerUser)
{
return BreadboardResult.Invalid([$"Project limit reached ({MaxProjectsPerUser} per account)"]);
}
var query = @"
INSERT INTO app.breadboard_projects (user_id, name, description, circuit, created_at, updated_at)
VALUES (@userId, @name, @description, @circuit::jsonb, NOW(), NOW())
RETURNING id, name, description, circuit::text, created_at, updated_at";
var project = await db.ExecuteReaderAsync(query, MapProject, new
{
userId,
name = trimmedName,
description = trimmedDescription,
circuit = EmptyCircuitJson
});
return project is null
? BreadboardResult.Failed("Failed to create project")
: BreadboardResult.Succeeded(project);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to create breadboard project for user {UserId}", userId);
return BreadboardResult.Failed("Failed to create project");
}
}
/// <summary>
/// Partial update: a null argument means "leave unchanged". An explicitly blank
/// description clears the column.
/// </summary>
public async Task<BreadboardResult> UpdateProjectAsync(
long projectId,
long userId,
string? name,
string? description,
string? circuitJson)
{
var errors = new List<string>();
var setName = name is not null;
var trimmedName = setName ? ValidateName(name, errors) : null;
var setDescription = description is not null;
var trimmedDescription = setDescription ? ValidateDescription(description, errors) : null;
var setCircuit = circuitJson is not null;
if (setCircuit)
{
var circuitCheck = validator.Validate(circuitJson!);
if (!circuitCheck.IsValid)
{
errors.AddRange(circuitCheck.Errors);
}
}
if (errors.Count > 0)
{
return BreadboardResult.Invalid(errors);
}
try
{
var query = @"
UPDATE app.breadboard_projects
SET name = CASE WHEN @setName THEN @name ELSE name END,
-- NULLIF keeps @description a non-null text parameter, so the server never has
-- to infer a type for an untyped NULL, and clearing still works.
description = CASE WHEN @setDescription THEN NULLIF(@description, '') ELSE description END,
circuit = CASE WHEN @setCircuit THEN @circuit::jsonb ELSE circuit END,
-- A no-op save must not reorder the project list, which sorts on updated_at.
updated_at = CASE WHEN @setName OR @setDescription OR @setCircuit THEN NOW() ELSE updated_at END
WHERE id = @projectId AND user_id = @userId";
var rows = await db.ExecuteNonQueryAsync(query, new
{
projectId,
userId,
setName,
// Guarded by @setName — the CASE is what keeps this placeholder off the column.
name = trimmedName ?? string.Empty,
setDescription,
description = trimmedDescription ?? string.Empty,
setCircuit,
// Guarded by @setCircuit, but the ::jsonb cast still parses it, so it must be
// valid JSON even on the branch the CASE discards.
circuit = circuitJson ?? EmptyCircuitJson
});
return rows == 0 ? BreadboardResult.Missing() : BreadboardResult.Succeeded();
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to update breadboard project {ProjectId} for user {UserId}", projectId, userId);
return BreadboardResult.Failed("Failed to update project");
}
}
public async Task<BreadboardResult> DeleteProjectAsync(long projectId, long userId)
{
try
{
var query = "DELETE FROM app.breadboard_projects WHERE id = @projectId AND user_id = @userId";
var rows = await db.ExecuteNonQueryAsync(query, new { projectId, userId });
return rows == 0 ? BreadboardResult.Missing() : BreadboardResult.Succeeded();
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to delete breadboard project {ProjectId} for user {UserId}", projectId, userId);
return BreadboardResult.Failed("Failed to delete project");
}
}
private static BreadboardProjectSummary MapSummary(NpgsqlDataReader reader) => new(
reader.GetInt64(0),
reader.GetString(1),
reader.IsDBNull(2) ? null : reader.GetString(2),
reader.GetDateTime(3),
reader.GetDateTime(4));
private BreadboardProject MapProject(NpgsqlDataReader reader) => new(
reader.GetInt64(0),
reader.GetString(1),
reader.IsDBNull(2) ? null : reader.GetString(2),
ParseCircuit(reader.GetString(3), reader.GetInt64(0)),
reader.GetDateTime(4),
reader.GetDateTime(5));
private JsonNode? ParseCircuit(string circuitJson, long projectId)
{
try
{
return JsonNode.Parse(circuitJson);
}
catch (Exception ex)
{
logger.LogError(ex, "Stored circuit for breadboard project {ProjectId} is not parseable JSON", projectId);
return null;
}
}
private static string ValidateName(string? name, List<string> errors)
{
var trimmed = name?.Trim() ?? string.Empty;
if (trimmed.Length == 0)
{
errors.Add("Name is required");
}
else if (trimmed.Length > MaxNameLength)
{
errors.Add($"Name must be {MaxNameLength} characters or fewer");
}
else if (trimmed.Any(char.IsControl))
{
// PostgreSQL rejects NUL in text outright; catching it here makes it a 400 rather
// than a generic 500, and the rest of the control range has no business in a name.
errors.Add("Name must not contain control characters");
}
return trimmed;
}
private static string? ValidateDescription(string? description, List<string> errors)
{
var trimmed = description?.Trim();
if (string.IsNullOrEmpty(trimmed))
{
return null;
}
if (trimmed.Length > MaxDescriptionLength)
{
errors.Add($"Description must be {MaxDescriptionLength} characters or fewer");
}
else if (trimmed.Any(c => char.IsControl(c) && c is not ('\n' or '\r' or '\t')))
{
// Line breaks and tabs are legitimate in free text — CR is in the list because a
// <textarea> submits CRLF. A NUL is not legitimate: PostgreSQL cannot store it in a
// text column at all, so blocking it here makes it a 400 instead of a 500.
// This allow-list is a team-lead ruling, not a local preference; Name is
// deliberately stricter because it is a single-line label.
errors.Add("Description must not contain control characters");
}
return trimmed;
}
}
@@ -0,0 +1,973 @@
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace Media.JoshHeaps.Net.Services;
/// <summary>
/// Outcome of validating a breadboard circuit document. Errors are terse
/// machine-readable "path:reason" tokens that are safe to return to the client:
/// they never echo unbounded user input, file paths, or exception detail.
/// </summary>
public sealed record BreadboardValidationResult(bool IsValid, IReadOnlyList<string> Errors)
{
public static BreadboardValidationResult Ok() => new(true, Array.Empty<string>());
public static BreadboardValidationResult Fail(IReadOnlyList<string> errors) => new(false, errors);
}
/// <summary>
/// Server-side structural validation of the circuit JSONB document (schema v1)
/// before persistence. Never throws for expected-invalid input — malformed JSON,
/// wrong types, missing fields and out-of-range values all come back as errors.
/// Stateless and thread-safe.
/// </summary>
public class BreadboardValidator(ILogger<BreadboardValidator> logger)
{
/// <summary>Only schema version 1 exists in milestone 1.</summary>
public const int SupportedVersion = 1;
/// <summary>Hard cap on the raw UTF-8 size of the circuit document (2 MB, per the team contract).</summary>
public const int MaxCircuitBytes = 2 * 1024 * 1024;
/// <summary>A realistic desk holds a handful of boards; 50 is generous and bounds net extraction cost.</summary>
public const int MaxBoards = 50;
/// <summary>Team amendment A11: pinned to the JS shared/ constants. Higher counts could exceed the 2 MB byte cap.</summary>
public const int MaxComponents = 3000;
/// <summary>Team amendment A11: pinned to the JS shared/ constants, and bounds union-find work in the engine.</summary>
public const int MaxWires = 10000;
/// <summary>Per the schema contract: any string up to 40 chars.</summary>
public const int MaxUidLength = 40;
/// <summary>The deepest legal path in schema v1 is 6 (circuit > components > component > props > to > value), so 8 is ample.</summary>
public const int MaxJsonDepth = 8;
/// <summary>No schema string is meaningfully longer than this; keeps a multi-megabyte "color" from being compared or echoed.</summary>
public const int MaxStringLength = 64;
/// <summary>Cap on returned errors so a hostile document cannot produce a huge response.</summary>
public const int MaxErrors = 50;
/// <summary>Full-size 830-point board: 63 columns in the main grid.</summary>
public const int MainColumnMin = 1;
public const int MainColumnMax = 63;
/// <summary>Each power rail exposes 50 tie points.</summary>
public const int RailIndexMin = 1;
public const int RailIndexMax = 50;
/// <summary>Board canvas positions are world-space pixels; anything beyond this is nonsense.</summary>
public const double MaxBoardCoordinate = 1_000_000d;
/// <summary>Resistance must be a positive, finite, physically plausible value.</summary>
public const double MaxResistorOhms = 1e9;
// Column footprints, mirrored from the frontend's shared/component-pins.js so a document
// the client accepts is a document the server accepts.
private const int ChipColumnSpan = 7; // 14-pin DIP
private const int DipSwitchColumnSpan = 8; // 16-pin DIP
private const int PushButtonColumnSpan = 3;
private const int TransistorColumnSpan = 3; // TO-92, three legs one column apart
private const int DipSwitchPositions = 8;
private static readonly Regex UidPattern = new($@"^[A-Za-z0-9_.:-]{{1,{MaxUidLength}}}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex HexColorPattern = new(@"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex SafeNamePattern = new(@"^[A-Za-z0-9_]{1,32}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly HashSet<string> TopLevelKeys = new(StringComparer.Ordinal) { "version", "boards", "components", "wires" };
private static readonly HashSet<string> BoardKeys = new(StringComparer.Ordinal) { "uid", "x", "y" };
private static readonly HashSet<string> ComponentKeys = new(StringComparer.Ordinal) { "uid", "type", "anchor", "orient", "props" };
private static readonly HashSet<string> WireKeys = new(StringComparer.Ordinal) { "uid", "from", "to", "color" };
private static readonly HashSet<string> MainHoleKeys = new(StringComparer.Ordinal) { "board", "kind", "col", "row" };
private static readonly HashSet<string> RailHoleKeys = new(StringComparer.Ordinal) { "board", "kind", "rail", "index" };
private static readonly HashSet<string> MainRows = new(StringComparer.Ordinal) { "a", "b", "c", "d", "e", "f", "g", "h", "i", "j" };
/// <summary>Pin 1 of a gap-straddling package sits in one of the two rows flanking the centre channel.</summary>
private static readonly HashSet<string> GapAnchorRows = new(StringComparer.Ordinal) { "e", "f" };
private static readonly HashSet<string> RailNames = new(StringComparer.Ordinal) { "topPlus", "topMinus", "bottomPlus", "bottomMinus" };
private static readonly HashSet<string> Orientations = new(StringComparer.Ordinal) { "up", "down", "left", "right" };
/// <summary>A DIP package cannot straddle the gap vertically, so it has exactly two orientations 180 degrees apart.</summary>
private static readonly HashSet<string> PackageOrientations = new(StringComparer.Ordinal) { "left", "right" };
private static readonly HashSet<string> SupplySides = new(StringComparer.Ordinal) { "top", "bottom" };
private static readonly HashSet<string> NamedColors = new(StringComparer.Ordinal)
{
"red", "green", "blue", "yellow", "orange", "white", "amber", "purple", "black", "gray"
};
private static readonly HashSet<string> LedColors = new(StringComparer.Ordinal)
{
"red", "green", "blue", "yellow", "orange", "white", "amber", "purple"
};
private static readonly HashSet<string> DipChipTypes = new(StringComparer.Ordinal)
{
"74HC00", "74HC02", "74HC04", "74HC08", "74HC32", "74HC86", "74HC30"
};
private static readonly HashSet<string> ComponentTypes = new(StringComparer.Ordinal)
{
"led", "resistor", "diode", "npn", "pnp", "nmos", "pmos",
"pushButton", "dipSwitch8", "powerSupply5V",
"74HC00", "74HC02", "74HC04", "74HC08", "74HC32", "74HC86", "74HC30"
};
private static readonly HashSet<string> LedPropKeys = new(StringComparer.Ordinal) { "color" };
private static readonly HashSet<string> ResistorPropKeys = new(StringComparer.Ordinal) { "ohms", "to" };
private static readonly HashSet<string> DipSwitchPropKeys = new(StringComparer.Ordinal) { "on" };
private static readonly HashSet<string> SupplyPropKeys = new(StringComparer.Ordinal) { "board", "side" };
private static readonly HashSet<string> NoPropKeys = new(StringComparer.Ordinal);
/// <summary>
/// Validates the raw circuit JSON exactly as it will be persisted. This is the
/// only entry point, so the byte-size cap can never be bypassed.
/// </summary>
public BreadboardValidationResult Validate(string? circuitJson)
{
try
{
if (string.IsNullOrWhiteSpace(circuitJson))
{
return BreadboardValidationResult.Fail(["circuit:missing"]);
}
// Measured on UTF-8 bytes, before any parsing work is done.
if (Encoding.UTF8.GetByteCount(circuitJson) > MaxCircuitBytes)
{
return BreadboardValidationResult.Fail(["circuit:exceeds_max_size"]);
}
JsonDocument document;
try
{
document = JsonDocument.Parse(circuitJson, new JsonDocumentOptions
{
MaxDepth = MaxJsonDepth,
CommentHandling = JsonCommentHandling.Disallow,
AllowTrailingCommas = false
});
}
catch (JsonException)
{
return BreadboardValidationResult.Fail(["circuit:malformed_json"]);
}
using (document)
{
var errors = new ErrorList();
ValidateDocument(document.RootElement, errors);
return errors.HasErrors
? BreadboardValidationResult.Fail(errors.Build())
: BreadboardValidationResult.Ok();
}
}
catch (Exception ex)
{
logger.LogError(ex, "Unexpected failure validating breadboard circuit document");
return BreadboardValidationResult.Fail(["circuit:validation_failed"]);
}
}
private static void ValidateDocument(JsonElement circuit, ErrorList errors)
{
if (circuit.ValueKind != JsonValueKind.Object)
{
errors.Add("circuit", "not_an_object");
return;
}
CheckKeys(circuit, "circuit", TopLevelKeys, errors);
ValidateVersion(circuit, errors);
// uids are unique across the whole document, so a single set covers all three collections.
var allUids = new HashSet<string>(StringComparer.Ordinal);
var boardUids = ValidateBoards(circuit, allUids, errors);
ValidateComponents(circuit, boardUids, allUids, errors);
ValidateWires(circuit, boardUids, allUids, errors);
}
private static void ValidateVersion(JsonElement circuit, ErrorList errors)
{
if (!circuit.TryGetProperty("version", out var version))
{
errors.Add("version", "missing");
return;
}
if (version.ValueKind != JsonValueKind.Number || !version.TryGetInt32(out var value) || value != SupportedVersion)
{
errors.Add("version", "unsupported");
}
}
private static HashSet<string> ValidateBoards(JsonElement circuit, HashSet<string> allUids, ErrorList errors)
{
var boardUids = new HashSet<string>(StringComparer.Ordinal);
if (!TryGetArray(circuit, "boards", MaxBoards, errors, out var boards))
{
return boardUids;
}
var index = 0;
foreach (var board in boards.EnumerateArray())
{
var path = $"boards[{index++}]";
if (board.ValueKind != JsonValueKind.Object)
{
errors.Add(path, "not_an_object");
continue;
}
CheckKeys(board, path, BoardKeys, errors);
var uid = ReadUid(board, path, allUids, errors);
if (uid is not null)
{
boardUids.Add(uid);
}
ReadCoordinate(board, "x", path, errors);
ReadCoordinate(board, "y", path, errors);
}
return boardUids;
}
private static void ValidateComponents(JsonElement circuit, HashSet<string> boardUids, HashSet<string> allUids, ErrorList errors)
{
if (!TryGetArray(circuit, "components", MaxComponents, errors, out var components))
{
return;
}
// One supply per rail pair: two supplies on the same pair is a contradiction the
// engine would have to resolve as spurious contention.
var supplySlots = new HashSet<(string Board, string Side)>();
var index = 0;
foreach (var component in components.EnumerateArray())
{
var path = $"components[{index++}]";
if (component.ValueKind != JsonValueKind.Object)
{
errors.Add(path, "not_an_object");
continue;
}
CheckKeys(component, path, ComponentKeys, errors);
ReadUid(component, path, allUids, errors);
var type = ReadEnum(component, "type", path, ComponentTypes, errors, required: true);
if (type is null)
{
continue;
}
ValidateComponentByType(component, path, type, boardUids, supplySlots, errors);
}
}
/// <summary>
/// Every branch must account for `orient`, either by validating it or by rejecting it —
/// a key that is in <see cref="ComponentKeys"/> but unvalidated is an arbitrary
/// attacker-controlled slot in the stored document, which is exactly what the
/// unknown-property allowlist exists to prevent.
/// </summary>
private static void ValidateComponentByType(
JsonElement component,
string path,
string type,
HashSet<string> boardUids,
HashSet<(string Board, string Side)> supplySlots,
ErrorList errors)
{
switch (type)
{
case "led":
ValidateTwoHoleSpan(component, path, boardUids, errors);
ValidateLedProps(component, path, errors);
break;
case "diode":
ValidateTwoHoleSpan(component, path, boardUids, errors);
ValidateEmptyProps(component, path, errors);
break;
case "npn":
case "pnp":
case "nmos":
case "pmos":
ValidateInlinePackage(component, path, TransistorColumnSpan, boardUids, errors);
ValidateEmptyProps(component, path, errors);
break;
case "resistor":
// The second terminal is explicit in props.to, so orientation is derived, not stored.
ReadAnchor(component, path, boardUids, errors, required: true);
RequireAbsent(component, "orient", path, errors);
ValidateResistorProps(component, path, boardUids, errors);
break;
case "pushButton":
ValidateGapPackage(component, path, PushButtonColumnSpan, boardUids, errors);
ValidateEmptyProps(component, path, errors);
break;
case "dipSwitch8":
ValidateGapPackage(component, path, DipSwitchColumnSpan, boardUids, errors);
ValidateDipSwitchProps(component, path, errors);
break;
case "powerSupply5V":
// Anchorless: both pins are derived from props, so an anchor would be meaningless data.
RequireAbsent(component, "anchor", path, errors);
RequireAbsent(component, "orient", path, errors);
ValidateSupplyProps(component, path, boardUids, supplySlots, errors);
break;
default:
// Reachable only if a type is added to ComponentTypes without a case here:
// a registry/dispatch drift then fails closed instead of skipping validation.
if (DipChipTypes.Contains(type))
{
ValidateGapPackage(component, path, ChipColumnSpan, boardUids, errors);
ValidateEmptyProps(component, path, errors);
}
else
{
errors.Add($"{path}.type", "unsupported");
}
break;
}
}
/// <summary>
/// A two-legged part spanning two holes: the anchor is the first terminal (an LED's anode,
/// a diode's anode) and the second sits one hole away in the orient direction. A leg in a
/// power rail is legal — electrically useless if both legs share a rail, but a real
/// breadboard allows it — so only the footprint is checked.
/// </summary>
private static void ValidateTwoHoleSpan(JsonElement component, string path, HashSet<string> boardUids, ErrorList errors)
{
var orient = ReadEnum(component, "orient", path, Orientations, errors, required: true);
var anchor = ReadAnchor(component, path, boardUids, errors, required: true);
if (anchor is null || orient is null)
{
return;
}
if (orient is "up" or "down")
{
// Vertical travel moves one row and may legitimately cross the centre channel. A
// rail has no rows, so the far leg of an up/down part anchored there has nowhere to go.
if (anchor.Kind != "main")
{
errors.Add($"{path}.orient", "not_valid_on_rail");
return;
}
// Rows a..j read top to bottom, so "up" decreases the row index — the same sign
// convention that maps "left"/"right" to -1/+1 on the column axis. Mirrors
// shared/board-geometry.js (team amendment A12 pins it as contract).
var farRow = (anchor.Row[0] - 'a') + (orient == "up" ? -1 : 1);
if (farRow < 0 || farRow >= MainRows.Count)
{
errors.Add($"{path}.anchor", "footprint_off_board");
}
return;
}
var farPosition = anchor.Position + (orient == "left" ? -1 : 1);
var min = anchor.Kind == "main" ? MainColumnMin : RailIndexMin;
var max = anchor.Kind == "main" ? MainColumnMax : RailIndexMax;
if (farPosition < min || farPosition > max)
{
errors.Add($"{path}.anchor", "footprint_off_board");
}
}
/// <summary>
/// A three-legged inline package (TO-92). Its legs run along one row of the main grid, one
/// column apart, so only "left" and "right" leave each leg in a strip of its own — a
/// vertical placement would put two legs in the same five-hole strip, and a power rail is
/// one continuous strip, which is why an anchor there is rejected outright.
/// </summary>
private static void ValidateInlinePackage(
JsonElement component,
string path,
int columnSpan,
HashSet<string> boardUids,
ErrorList errors)
{
var orient = ReadEnum(component, "orient", path, PackageOrientations, errors, required: true);
var anchor = ReadAnchor(component, path, boardUids, errors, required: true);
if (anchor is null || orient is null)
{
return;
}
if (anchor.Kind != "main")
{
errors.Add($"{path}.anchor.kind", "must_be_main");
return;
}
var lastColumn = anchor.Position + ((orient == "left" ? -1 : 1) * (columnSpan - 1));
if (lastColumn < MainColumnMin || lastColumn > MainColumnMax)
{
errors.Add($"{path}.anchor.col", "package_off_board");
}
}
/// <summary>
/// Packages that straddle the centre gap. Pin 1 sits in row "e" or "f" on the main grid
/// and the package runs across <paramref name="columnSpan"/> columns: row "e" reads left
/// to right, row "f" is the same package rotated 180 degrees and reads right to left.
/// Every pin must land on a real hole.
/// </summary>
private static void ValidateGapPackage(
JsonElement component,
string path,
int columnSpan,
HashSet<string> boardUids,
ErrorList errors)
{
var orient = ReadEnum(component, "orient", path, PackageOrientations, errors, required: true);
var anchor = ReadAnchor(component, path, boardUids, errors, required: true);
if (anchor is null)
{
return;
}
if (anchor.Kind != "main")
{
errors.Add($"{path}.anchor.kind", "must_be_main");
return;
}
if (!GapAnchorRows.Contains(anchor.Row))
{
errors.Add($"{path}.anchor.row", "must_straddle_gap");
return;
}
// Orientation and anchor row are redundant by construction, so a document carrying
// both is able to encode a contradiction. Reject that rather than pick a winner.
var step = anchor.Row == "e" ? 1 : -1;
if (orient is not null && orient != (step == 1 ? "right" : "left"))
{
errors.Add($"{path}.orient", "contradicts_anchor_row");
}
var lastColumn = anchor.Position + (step * (columnSpan - 1));
if (lastColumn < MainColumnMin || lastColumn > MainColumnMax)
{
errors.Add($"{path}.anchor.col", "package_off_board");
}
}
/// <summary>
/// Rejects a key that carries no meaning for this component type, so it cannot become an
/// unvalidated slot in the stored document. Presence is what is rejected, not just a
/// meaningful value: an explicit JSON null is a present property, and the editor's schema
/// refuses to load one, so accepting it here would persist a document the client cannot open.
/// </summary>
private static void RequireAbsent(JsonElement component, string name, string path, ErrorList errors)
{
if (component.TryGetProperty(name, out _))
{
errors.Add($"{path}.{name}", "not_applicable");
}
}
/// <summary>Saved switch positions, so a bank survives a reload. Optional; exactly eight booleans when present.</summary>
private static void ValidateDipSwitchProps(JsonElement component, string path, ErrorList errors)
{
if (!ReadProps(component, path, DipSwitchPropKeys, errors, required: false, out var props))
{
return;
}
if (!props.TryGetProperty("on", out var positions) || positions.ValueKind == JsonValueKind.Null)
{
return;
}
var onPath = $"{path}.props.on";
if (positions.ValueKind != JsonValueKind.Array)
{
errors.Add(onPath, "not_an_array");
return;
}
if (positions.GetArrayLength() != DipSwitchPositions)
{
errors.Add(onPath, "wrong_length");
return;
}
var index = 0;
foreach (var position in positions.EnumerateArray())
{
if (position.ValueKind is not (JsonValueKind.True or JsonValueKind.False))
{
errors.Add($"{onPath}[{index}]", "not_a_boolean");
}
index++;
}
}
private static void ValidateLedProps(JsonElement component, string path, ErrorList errors)
{
if (!ReadProps(component, path, LedPropKeys, errors, required: true, out var props))
{
return;
}
ReadColor(props, "color", $"{path}.props", LedColors, errors, required: true);
}
private static void ValidateResistorProps(JsonElement component, string path, HashSet<string> boardUids, ErrorList errors)
{
if (!ReadProps(component, path, ResistorPropKeys, errors, required: true, out var props))
{
return;
}
var propsPath = $"{path}.props";
if (!props.TryGetProperty("ohms", out var ohms))
{
errors.Add($"{propsPath}.ohms", "missing");
}
else if (ohms.ValueKind != JsonValueKind.Number || !ohms.TryGetDouble(out var value) || !double.IsFinite(value))
{
errors.Add($"{propsPath}.ohms", "not_a_number");
}
else if (value <= 0 || value > MaxResistorOhms)
{
errors.Add($"{propsPath}.ohms", "out_of_range");
}
// A resistor spans two holes and may reach across boards or onto a rail.
if (!props.TryGetProperty("to", out var to))
{
errors.Add($"{propsPath}.to", "missing");
}
else
{
ValidateHoleRef(to, $"{propsPath}.to", boardUids, errors);
}
}
private static void ValidateSupplyProps(
JsonElement component,
string path,
HashSet<string> boardUids,
HashSet<(string Board, string Side)> supplySlots,
ErrorList errors)
{
if (!ReadProps(component, path, SupplyPropKeys, errors, required: true, out var props))
{
return;
}
var propsPath = $"{path}.props";
var board = ValidateBoardReference(props, "board", propsPath, boardUids, errors);
var side = ReadEnum(props, "side", propsPath, SupplySides, errors, required: true);
if (board is not null && side is not null && !supplySlots.Add((board, side)))
{
errors.Add(propsPath, "rail_pair_already_supplied");
}
}
private static void ValidateEmptyProps(JsonElement component, string path, ErrorList errors)
{
ReadProps(component, path, NoPropKeys, errors, required: false, out _);
}
private static void ValidateWires(JsonElement circuit, HashSet<string> boardUids, HashSet<string> allUids, ErrorList errors)
{
if (!TryGetArray(circuit, "wires", MaxWires, errors, out var wires))
{
return;
}
var index = 0;
foreach (var wire in wires.EnumerateArray())
{
var path = $"wires[{index++}]";
if (wire.ValueKind != JsonValueKind.Object)
{
errors.Add(path, "not_an_object");
continue;
}
CheckKeys(wire, path, WireKeys, errors);
ReadUid(wire, path, allUids, errors);
foreach (var end in new[] { "from", "to" })
{
if (!wire.TryGetProperty(end, out var hole))
{
errors.Add($"{path}.{end}", "missing");
}
else
{
ValidateHoleRef(hole, $"{path}.{end}", boardUids, errors);
}
}
ReadColor(wire, "color", path, NamedColors, errors, required: false);
}
}
private static ParsedHole? ReadAnchor(JsonElement component, string path, HashSet<string> boardUids, ErrorList errors, bool required)
{
if (!component.TryGetProperty("anchor", out var anchor) || anchor.ValueKind == JsonValueKind.Null)
{
if (required)
{
errors.Add($"{path}.anchor", "missing");
}
return null;
}
return ValidateHoleRef(anchor, $"{path}.anchor", boardUids, errors);
}
private static ParsedHole? ValidateHoleRef(JsonElement hole, string path, HashSet<string> boardUids, ErrorList errors)
{
if (hole.ValueKind != JsonValueKind.Object)
{
errors.Add(path, "not_an_object");
return null;
}
ValidateBoardReference(hole, "board", path, boardUids, errors);
if (!hole.TryGetProperty("kind", out var kindElement))
{
errors.Add($"{path}.kind", "missing");
return null;
}
if (kindElement.ValueKind != JsonValueKind.String)
{
errors.Add($"{path}.kind", "not_a_string");
return null;
}
switch (kindElement.GetString())
{
case "main":
{
CheckKeys(hole, path, MainHoleKeys, errors);
var col = ReadInt(hole, "col", path, MainColumnMin, MainColumnMax, errors);
var row = ReadEnum(hole, "row", path, MainRows, errors, required: true);
return col is null || row is null ? null : new ParsedHole("main", col.Value, row);
}
case "rail":
{
CheckKeys(hole, path, RailHoleKeys, errors);
var rail = ReadEnum(hole, "rail", path, RailNames, errors, required: true);
var index = ReadInt(hole, "index", path, RailIndexMin, RailIndexMax, errors);
return rail is null || index is null ? null : new ParsedHole("rail", index.Value, rail);
}
default:
errors.Add($"{path}.kind", "unsupported");
return null;
}
}
private static bool TryGetArray(JsonElement circuit, string name, int maxLength, ErrorList errors, out JsonElement array)
{
array = default;
if (!circuit.TryGetProperty(name, out var element) || element.ValueKind == JsonValueKind.Null)
{
return false;
}
if (element.ValueKind != JsonValueKind.Array)
{
errors.Add(name, "not_an_array");
return false;
}
if (element.GetArrayLength() > maxLength)
{
errors.Add(name, "too_many");
return false;
}
array = element;
return true;
}
private static bool ReadProps(JsonElement component, string path, IReadOnlySet<string> allowed, ErrorList errors, bool required, out JsonElement props)
{
if (!component.TryGetProperty("props", out props) || props.ValueKind == JsonValueKind.Null)
{
if (required)
{
errors.Add($"{path}.props", "missing");
}
return false;
}
if (props.ValueKind != JsonValueKind.Object)
{
errors.Add($"{path}.props", "not_an_object");
return false;
}
CheckKeys(props, $"{path}.props", allowed, errors);
return true;
}
private static string? ReadUid(JsonElement element, string path, HashSet<string> allUids, ErrorList errors)
{
var uidPath = $"{path}.uid";
if (!element.TryGetProperty("uid", out var uidElement))
{
errors.Add(uidPath, "missing");
return null;
}
if (uidElement.ValueKind != JsonValueKind.String)
{
errors.Add(uidPath, "not_a_string");
return null;
}
var uid = uidElement.GetString()!;
if (uid.Length > MaxUidLength)
{
errors.Add(uidPath, "too_long");
return null;
}
if (!UidPattern.IsMatch(uid))
{
errors.Add(uidPath, "invalid_format");
return null;
}
if (!allUids.Add(uid))
{
errors.Add(uidPath, "duplicate");
return null;
}
return uid;
}
private static void ReadCoordinate(JsonElement element, string name, string path, ErrorList errors)
{
var coordinatePath = $"{path}.{name}";
if (!element.TryGetProperty(name, out var value))
{
errors.Add(coordinatePath, "missing");
return;
}
if (value.ValueKind != JsonValueKind.Number || !value.TryGetDouble(out var number) || !double.IsFinite(number))
{
errors.Add(coordinatePath, "not_a_number");
return;
}
if (Math.Abs(number) > MaxBoardCoordinate)
{
errors.Add(coordinatePath, "out_of_range");
}
}
private static int? ReadInt(JsonElement element, string name, string path, int min, int max, ErrorList errors)
{
var intPath = $"{path}.{name}";
if (!element.TryGetProperty(name, out var value))
{
errors.Add(intPath, "missing");
return null;
}
if (value.ValueKind != JsonValueKind.Number || !value.TryGetInt32(out var number))
{
errors.Add(intPath, "not_an_integer");
return null;
}
if (number < min || number > max)
{
errors.Add(intPath, "out_of_range");
return null;
}
return number;
}
private static string? ReadEnum(JsonElement element, string name, string path, IReadOnlySet<string> allowed, ErrorList errors, bool required)
{
var enumPath = $"{path}.{name}";
if (!element.TryGetProperty(name, out var value) || value.ValueKind == JsonValueKind.Null)
{
if (required)
{
errors.Add(enumPath, "missing");
}
return null;
}
if (value.ValueKind != JsonValueKind.String)
{
errors.Add(enumPath, "not_a_string");
return null;
}
var text = value.GetString()!;
if (text.Length > MaxStringLength || !allowed.Contains(text))
{
errors.Add(enumPath, "unsupported");
return null;
}
return text;
}
private static void ReadColor(JsonElement element, string name, string path, IReadOnlySet<string> namedColors, ErrorList errors, bool required)
{
var colorPath = $"{path}.{name}";
if (!element.TryGetProperty(name, out var value) || value.ValueKind == JsonValueKind.Null)
{
if (required)
{
errors.Add(colorPath, "missing");
}
return;
}
if (value.ValueKind != JsonValueKind.String)
{
errors.Add(colorPath, "not_a_string");
return;
}
var color = value.GetString()!;
if (color.Length > MaxStringLength || (!namedColors.Contains(color) && !HexColorPattern.IsMatch(color)))
{
errors.Add(colorPath, "unsupported");
}
}
/// <summary>Reads a board uid reference and confirms it names a board declared in this document.</summary>
private static string? ValidateBoardReference(JsonElement element, string name, string path, HashSet<string> boardUids, ErrorList errors)
{
var boardPath = $"{path}.{name}";
if (!element.TryGetProperty(name, out var board))
{
errors.Add(boardPath, "missing");
return null;
}
if (board.ValueKind != JsonValueKind.String)
{
errors.Add(boardPath, "not_a_string");
return null;
}
var uid = board.GetString()!;
if (uid.Length > MaxUidLength || !boardUids.Contains(uid))
{
errors.Add(boardPath, "unknown_board");
return null;
}
return uid;
}
/// <summary>
/// Rejects properties outside the schema and duplicate JSON keys (System.Text.Json
/// tolerates duplicates, which would otherwise let a document smuggle a second value
/// past whichever occurrence the reader picks).
/// </summary>
private static void CheckKeys(JsonElement element, string path, IReadOnlySet<string> allowed, ErrorList errors)
{
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var property in element.EnumerateObject())
{
if (!allowed.Contains(property.Name))
{
errors.Add($"{path}.{SafeName(property.Name)}", "unknown_property");
}
else if (!seen.Add(property.Name))
{
errors.Add($"{path}.{property.Name}", "duplicate_property");
}
}
}
/// <summary>Unknown property names come from untrusted input, so only echo them when they are plainly safe.</summary>
private static string SafeName(string name) => SafeNamePattern.IsMatch(name) ? name : "?";
/// <summary>
/// A hole reference that passed validation. <paramref name="Position"/> is the column for
/// a main-grid hole and the tie-point index for a rail hole; <paramref name="Row"/> is the
/// row letter or the rail name respectively.
/// </summary>
private sealed record ParsedHole(string Kind, int Position, string Row);
private sealed class ErrorList
{
private readonly List<string> _errors = [];
private bool _truncated;
public bool HasErrors => _errors.Count > 0;
public void Add(string path, string reason)
{
if (_errors.Count >= MaxErrors)
{
_truncated = true;
return;
}
_errors.Add($"{path}:{reason}");
}
public IReadOnlyList<string> Build() => _truncated ? [.. _errors, "errors:truncated"] : _errors;
}
}
+1 -1
View File
@@ -107,7 +107,7 @@ If you didn't create an account, you can safely ignore this email.
try try
{ {
var appUrl = config["AppUrl"] ?? "http://localhost:5000"; var appUrl = config["AppUrl"] ?? "http://localhost:5000";
var resetUrl = $"{appUrl}/ResetPassword?token={resetToken}"; var resetUrl = $"{appUrl}/LoginHelp?token={resetToken}";
var message = new MimeMessage(); var message = new MimeMessage();
message.From.Add(new MailboxAddress( message.From.Add(new MailboxAddress(
@@ -123,7 +123,7 @@ public class MedicalAiService
{ {
try try
{ {
var doctor = await medicalDocsService.FindOrCreateDoctorByNameAsync(doctorName.Trim()); var doctor = await medicalDocsService.FindOrCreateDoctorByNameAsync(doc.PersonId, doctorName.Trim(), this);
doctorId = doctor?.Id; doctorId = doctor?.Id;
} }
catch (Exception ex) catch (Exception ex)
@@ -263,7 +263,7 @@ public class MedicalAiService
- ""doctorName"": string or null — the individual doctor/physician name mentioned (e.g. ""Dr. John Smith"" → ""John Smith""). If multiple, pick the primary/treating physician. - ""doctorName"": string or null — the individual doctor/physician name mentioned (e.g. ""Dr. John Smith"" → ""John Smith""). If multiple, pick the primary/treating physician.
- ""conditionNames"": array of medical condition names mentioned or clearly implied (e.g. ""Type 2 Diabetes"", ""Hypertension""). Only include conditions you can confidently identify. Use standard medical terminology. Return empty array if none are obvious. - ""conditionNames"": array of medical condition names mentioned or clearly implied (e.g. ""Type 2 Diabetes"", ""Hypertension""). Only include conditions you can confidently identify. Use standard medical terminology. Return empty array if none are obvious.
Return ONLY the JSON object, no other text."; Return ONLY the JSON object, no other text. If a document is a receipt for an individual prescription, label it as a prescription.";
var userPrompt = $"Analyze this medical document text and classify it.\n\nDocument text:\n{truncatedText}"; var userPrompt = $"Analyze this medical document text and classify it.\n\nDocument text:\n{truncatedText}";
@@ -447,7 +447,7 @@ Only include fields you can confidently extract. Return ONLY the JSON object, no
return null; return null;
} }
_logger.LogError("claude CLI exited with code {ExitCode}: {Stderr}", process.ExitCode, stderr); _logger.LogError("claude CLI exited with code {ExitCode}.\nStderr: {Stderr}\nStdout: {Stdout}", process.ExitCode, stderr, stdout);
return null; return null;
} }
@@ -631,6 +631,42 @@ Consider abbreviations, slight misspellings, and variations (e.g., ""Intermounta
} }
} }
public async Task<string?> FuzzyMatchDoctorAsync(string extractedName, List<string> existingDoctorNames)
{
try
{
var doctorList = string.Join("\n", existingDoctorNames.Select(n => $" - {n}"));
var systemPrompt = @"You are matching a doctor name extracted from a medical document against a list of known doctors for a patient. Return ONLY a JSON object with:
- ""matchedName"": the exact string from the existing list that matches, or null if no match
- ""confidence"": ""high"", ""medium"", or ""low""
Consider abbreviations, titles, and variations (e.g., ""Dr. John Smith"" matches ""John Smith"", ""J. Smith MD"" matches ""John Smith"", ""Smith, John"" matches ""John Smith""). Only return a match with medium or high confidence.";
var userPrompt = $"Extracted doctor name: \"{extractedName}\"\n\nExisting doctors:\n{doctorList}";
var response = await CallClaudeCliAsync(systemPrompt, userPrompt, HaikuModel);
if (response == null) return null;
var json = ExtractJson(response);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var matchedName = root.TryGetProperty("matchedName", out var mn) && mn.ValueKind == JsonValueKind.String ? mn.GetString() : null;
var confidence = root.TryGetProperty("confidence", out var conf) ? conf.GetString() : "low";
if (matchedName != null && confidence != "low")
return matchedName;
return null;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Fuzzy doctor matching failed for \"{ExtractedName}\"", extractedName);
return null;
}
}
public async Task<string?> FuzzyMatchConditionAsync(string extractedName, List<string> existingConditionNames) public async Task<string?> FuzzyMatchConditionAsync(string extractedName, List<string> existingConditionNames)
{ {
try try
@@ -6,12 +6,16 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment,
{ {
// --- People --- // --- People ---
public async Task<List<MedicalPerson>> GetPeopleAsync() public async Task<List<MedicalPerson>> GetPeopleAsync(long userId)
{ {
try try
{ {
return await db.ExecuteListReaderAsync( return await db.ExecuteListReaderAsync(
"SELECT id, name, date_of_birth, notes, created_at, updated_at FROM app.medical_people ORDER BY name", @"SELECT mp.id, mp.name, mp.date_of_birth, mp.notes, mp.created_at, mp.updated_at
FROM app.medical_people mp
JOIN app.medical_people_access mpa ON mpa.person_id = mp.id
WHERE mpa.user_id = @userId
ORDER BY mp.name",
reader => new MedicalPerson reader => new MedicalPerson
{ {
Id = reader.GetInt64(0), Id = reader.GetInt64(0),
@@ -20,7 +24,8 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment,
Notes = reader.IsDBNull(3) ? null : reader.GetString(3), Notes = reader.IsDBNull(3) ? null : reader.GetString(3),
CreatedAt = reader.GetDateTime(4), CreatedAt = reader.GetDateTime(4),
UpdatedAt = reader.GetDateTime(5) UpdatedAt = reader.GetDateTime(5)
}); },
new { userId });
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -29,12 +34,12 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment,
} }
} }
public async Task<MedicalPerson?> CreatePersonAsync(string name, DateTime? dateOfBirth = null, string? notes = null) public async Task<MedicalPerson?> CreatePersonAsync(long userId, string name, DateTime? dateOfBirth = null, string? notes = null)
{ {
try try
{ {
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
return await db.ExecuteReaderAsync( var person = await db.ExecuteReaderAsync(
@"INSERT INTO app.medical_people (name, date_of_birth, notes, created_at, updated_at) @"INSERT INTO app.medical_people (name, date_of_birth, notes, created_at, updated_at)
VALUES (@name, @dateOfBirth, @notes, @createdAt, @updatedAt) VALUES (@name, @dateOfBirth, @notes, @createdAt, @updatedAt)
RETURNING id, name, date_of_birth, notes, created_at, updated_at", RETURNING id, name, date_of_birth, notes, created_at, updated_at",
@@ -48,6 +53,15 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment,
UpdatedAt = reader.GetDateTime(5) UpdatedAt = reader.GetDateTime(5)
}, },
new { name, dateOfBirth, notes, createdAt = now, updatedAt = now }); new { name, dateOfBirth, notes, createdAt = now, updatedAt = now });
if (person != null)
{
await db.ExecuteNonQueryAsync(
"INSERT INTO app.medical_people_access (person_id, user_id) VALUES (@personId, @userId) ON CONFLICT DO NOTHING",
new { personId = person.Id, userId });
}
return person;
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -56,6 +70,110 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment,
} }
} }
// --- People Access ---
public async Task<bool> HasAccessToPersonAsync(long userId, long personId)
{
try
{
return await db.ExecuteAsync<bool>(
"SELECT EXISTS(SELECT 1 FROM app.medical_people_access WHERE user_id = @userId AND person_id = @personId)",
new { userId, personId });
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to check person access");
return false;
}
}
public async Task<bool> GrantAccessAsync(long personId, long targetUserId)
{
try
{
await db.ExecuteNonQueryAsync(
"INSERT INTO app.medical_people_access (person_id, user_id) VALUES (@personId, @userId) ON CONFLICT DO NOTHING",
new { personId, userId = targetUserId });
return true;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to grant person access");
return false;
}
}
public async Task<bool> RevokeAccessAsync(long personId, long targetUserId)
{
try
{
var count = await db.ExecuteAsync<long>(
"SELECT COUNT(*) FROM app.medical_people_access WHERE person_id = @personId",
new { personId });
if (count <= 1)
return false;
await db.ExecuteNonQueryAsync(
"DELETE FROM app.medical_people_access WHERE person_id = @personId AND user_id = @userId",
new { personId, userId = targetUserId });
return true;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to revoke person access");
return false;
}
}
public async Task<List<PersonAccessUser>> GetPeopleAccessAsync(long personId)
{
try
{
return await db.ExecuteListReaderAsync(
@"SELECT u.id, u.username FROM app.users u
JOIN app.medical_people_access mpa ON mpa.user_id = u.id
WHERE mpa.person_id = @personId
ORDER BY u.username",
reader => new PersonAccessUser
{
Id = reader.GetInt64(0),
Username = reader.GetString(1)
},
new { personId });
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get person access list");
return [];
}
}
public async Task<long?> GetPersonIdForResourceAsync(string resourceType, long resourceId)
{
try
{
var sql = resourceType switch
{
"document" => "SELECT person_id FROM app.medical_documents WHERE id = @id",
"doctor" => "SELECT person_id FROM app.medical_doctors WHERE id = @id",
"condition" => "SELECT person_id FROM app.medical_conditions WHERE id = @id",
"prescription" => "SELECT person_id FROM app.medical_prescriptions WHERE id = @id",
"pickup" => "SELECT p.person_id FROM app.medical_prescription_pickups pk JOIN app.medical_prescriptions p ON pk.prescription_id = p.id WHERE pk.id = @id",
"provider" => "SELECT person_id FROM app.medical_billing_providers WHERE id = @id",
"provider-payment" => "SELECT bp.person_id FROM app.medical_provider_payments pp JOIN app.medical_billing_providers bp ON pp.provider_id = bp.id WHERE pp.id = @id",
"bill" => "SELECT person_id FROM app.medical_bills WHERE id = @id",
"bill-charge" => "SELECT b.person_id FROM app.medical_bill_charges bc JOIN app.medical_bills b ON bc.bill_id = b.id WHERE bc.id = @id",
_ => throw new ArgumentException($"Unknown resource type: {resourceType}")
};
return await db.ExecuteAsync<long?>(sql, new { id = resourceId });
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get person ID for {ResourceType} {ResourceId}", resourceType, resourceId);
return null;
}
}
// --- Documents --- // --- Documents ---
public async Task<MedicalDocument?> SaveDocumentAsync(long personId, IFormFile file, string? title = null, string? description = null, DateTime? documentDate = null, string? classification = null) public async Task<MedicalDocument?> SaveDocumentAsync(long personId, IFormFile file, string? title = null, string? description = null, DateTime? documentDate = null, string? classification = null)
@@ -180,11 +298,14 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment,
} }
} }
public async Task<List<MedicalDocument>> SearchDocumentsAsync(long personId, string? search = null, string? classification = null, string? documentType = null, long? doctorId = null, long? tagId = null, long? conditionId = null, DateTime? fromDate = null, DateTime? toDate = null, bool? aiProcessed = null, int offset = 0, int limit = 50) public async Task<List<MedicalDocument>> SearchDocumentsAsync(long? personId = null, string? search = null, string? classification = null, string? documentType = null, long? doctorId = null, long? tagId = null, long? conditionId = null, DateTime? fromDate = null, DateTime? toDate = null, bool? aiProcessed = null, long? accessUserId = null, int offset = 0, int limit = 50)
{ {
try try
{ {
var conditions = new List<string> { "person_id = @personId" }; var personFilter = personId.HasValue
? "person_id = @personId"
: "person_id IN (SELECT person_id FROM app.medical_people_access WHERE user_id = @accessUserId)";
var conditions = new List<string> { personFilter };
if (!string.IsNullOrWhiteSpace(search)) if (!string.IsNullOrWhiteSpace(search))
conditions.Add("(title ILIKE @search OR description ILIKE @search OR extracted_text ILIKE @search)"); conditions.Add("(title ILIKE @search OR description ILIKE @search OR extracted_text ILIKE @search)");
@@ -222,7 +343,8 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment,
return await db.ExecuteListReaderAsync(query, MapDocument, new return await db.ExecuteListReaderAsync(query, MapDocument, new
{ {
personId, personId = personId ?? 0L,
accessUserId = accessUserId ?? 0L,
search = !string.IsNullOrWhiteSpace(search) ? $"%{search}%" : (string?)null, search = !string.IsNullOrWhiteSpace(search) ? $"%{search}%" : (string?)null,
classification, classification,
documentType, documentType,
@@ -243,16 +365,20 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment,
} }
} }
public async Task<List<MedicalTag>> GetPersonTagsAsync(long personId) public async Task<List<MedicalTag>> GetPersonTagsAsync(long? personId = null, long? accessUserId = null)
{ {
try try
{ {
var personFilter = personId.HasValue
? "d.person_id = @personId"
: "d.person_id IN (SELECT person_id FROM app.medical_people_access WHERE user_id = @accessUserId)";
return await db.ExecuteListReaderAsync( return await db.ExecuteListReaderAsync(
@"SELECT DISTINCT t.id, t.name, t.created_at $@"SELECT DISTINCT t.id, t.name, t.created_at
FROM app.medical_tags t FROM app.medical_tags t
JOIN app.medical_document_tags dt ON dt.tag_id = t.id JOIN app.medical_document_tags dt ON dt.tag_id = t.id
JOIN app.medical_documents d ON dt.document_id = d.id JOIN app.medical_documents d ON dt.document_id = d.id
WHERE d.person_id = @personId WHERE {personFilter}
ORDER BY t.name", ORDER BY t.name",
reader => new MedicalTag reader => new MedicalTag
{ {
@@ -260,7 +386,7 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment,
Name = reader.GetString(1), Name = reader.GetString(1),
CreatedAt = reader.GetDateTime(2) CreatedAt = reader.GetDateTime(2)
}, },
new { personId }); new { personId = personId ?? 0L, accessUserId = accessUserId ?? 0L });
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -380,39 +506,45 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment,
// --- Doctors --- // --- Doctors ---
public async Task<List<MedicalDoctor>> GetDoctorsAsync() public async Task<List<MedicalDoctor>> GetDoctorsAsync(long? personId = null, long? accessUserId = null)
{ {
try try
{ {
var personFilter = personId.HasValue
? "person_id = @personId"
: "person_id IN (SELECT person_id FROM app.medical_people_access WHERE user_id = @accessUserId)";
return await db.ExecuteListReaderAsync( return await db.ExecuteListReaderAsync(
"SELECT id, name, specialty, phone, address, notes, created_at, updated_at FROM app.medical_doctors ORDER BY name", $"SELECT id, person_id, name, specialty, phone, address, notes, created_at, updated_at FROM app.medical_doctors WHERE {personFilter} ORDER BY name",
reader => new MedicalDoctor reader => new MedicalDoctor
{ {
Id = reader.GetInt64(0), Id = reader.GetInt64(0),
Name = reader.GetString(1), PersonId = reader.GetInt64(1),
Specialty = reader.IsDBNull(2) ? null : reader.GetString(2), Name = reader.GetString(2),
Phone = reader.IsDBNull(3) ? null : reader.GetString(3), Specialty = reader.IsDBNull(3) ? null : reader.GetString(3),
Address = reader.IsDBNull(4) ? null : reader.GetString(4), Phone = reader.IsDBNull(4) ? null : reader.GetString(4),
Notes = reader.IsDBNull(5) ? null : reader.GetString(5), Address = reader.IsDBNull(5) ? null : reader.GetString(5),
CreatedAt = reader.GetDateTime(6), Notes = reader.IsDBNull(6) ? null : reader.GetString(6),
UpdatedAt = reader.GetDateTime(7) CreatedAt = reader.GetDateTime(7),
}); UpdatedAt = reader.GetDateTime(8)
},
new { personId = personId ?? 0L, accessUserId = accessUserId ?? 0L });
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Failed to get doctors"); logger.LogError(ex, "Failed to get doctors for person {PersonId}", personId);
return []; return [];
} }
} }
public async Task<MedicalDoctor?> CreateDoctorAsync(string name, string? specialty = null, string? phone = null, string? address = null, string? notes = null) public async Task<MedicalDoctor?> CreateDoctorAsync(long personId, string name, string? specialty = null, string? phone = null, string? address = null, string? notes = null)
{ {
try try
{ {
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
return await db.ExecuteReaderAsync( return await db.ExecuteReaderAsync(
@"INSERT INTO app.medical_doctors (name, specialty, phone, address, notes, created_at, updated_at) @"INSERT INTO app.medical_doctors (person_id, name, specialty, phone, address, notes, created_at, updated_at)
VALUES (@name, @specialty, @phone, @address, @notes, @createdAt, @updatedAt) VALUES (@personId, @name, @specialty, @phone, @address, @notes, @createdAt, @updatedAt)
RETURNING id, name, specialty, phone, address, notes, created_at, updated_at", RETURNING id, name, specialty, phone, address, notes, created_at, updated_at",
reader => new MedicalDoctor reader => new MedicalDoctor
{ {
@@ -425,11 +557,11 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment,
CreatedAt = reader.GetDateTime(6), CreatedAt = reader.GetDateTime(6),
UpdatedAt = reader.GetDateTime(7) UpdatedAt = reader.GetDateTime(7)
}, },
new { name, specialty, phone, address, notes, createdAt = now, updatedAt = now }); new { personId, name, specialty, phone, address, notes, createdAt = now, updatedAt = now });
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Failed to create doctor"); logger.LogError(ex, "Failed to create doctor for person {PersonId}", personId);
return null; return null;
} }
} }
@@ -493,12 +625,16 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment,
// --- Conditions --- // --- Conditions ---
public async Task<List<MedicalCondition>> GetConditionsAsync(long personId) public async Task<List<MedicalCondition>> GetConditionsAsync(long? personId = null, long? accessUserId = null)
{ {
try try
{ {
var personFilter = personId.HasValue
? "person_id = @personId"
: "person_id IN (SELECT person_id FROM app.medical_people_access WHERE user_id = @accessUserId)";
return await db.ExecuteListReaderAsync( return await db.ExecuteListReaderAsync(
"SELECT id, person_id, name, diagnosed_date, notes, is_active, created_at, updated_at FROM app.medical_conditions WHERE person_id = @personId ORDER BY name", $"SELECT id, person_id, name, diagnosed_date, notes, is_active, created_at, updated_at FROM app.medical_conditions WHERE {personFilter} ORDER BY name",
reader => new MedicalCondition reader => new MedicalCondition
{ {
Id = reader.GetInt64(0), Id = reader.GetInt64(0),
@@ -510,7 +646,7 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment,
CreatedAt = reader.GetDateTime(6), CreatedAt = reader.GetDateTime(6),
UpdatedAt = reader.GetDateTime(7) UpdatedAt = reader.GetDateTime(7)
}, },
new { personId }); new { personId = personId ?? 0L, accessUserId = accessUserId ?? 0L });
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -581,17 +717,21 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment,
// --- Prescriptions --- // --- Prescriptions ---
public async Task<List<MedicalPrescription>> GetPrescriptionsAsync(long personId) public async Task<List<MedicalPrescription>> GetPrescriptionsAsync(long? personId = null, long? accessUserId = null)
{ {
try try
{ {
var personFilter = personId.HasValue
? "p.person_id = @personId"
: "p.person_id IN (SELECT person_id FROM app.medical_people_access WHERE user_id = @accessUserId)";
return await db.ExecuteListReaderAsync( return await db.ExecuteListReaderAsync(
@"SELECT p.id, p.person_id, p.doctor_id, p.medication_name, p.dosage, p.frequency, p.is_active, p.start_date, p.end_date, p.notes, p.created_at, p.updated_at, p.rx_number, $@"SELECT p.id, p.person_id, p.doctor_id, p.medication_name, p.dosage, p.frequency, p.is_active, p.start_date, p.end_date, p.notes, p.created_at, p.updated_at, p.rx_number,
d.name AS doctor_name, d.name AS doctor_name,
(SELECT MAX(pk.pickup_date) FROM app.medical_prescription_pickups pk WHERE pk.prescription_id = p.id) AS last_pickup (SELECT MAX(pk.pickup_date) FROM app.medical_prescription_pickups pk WHERE pk.prescription_id = p.id) AS last_pickup
FROM app.medical_prescriptions p FROM app.medical_prescriptions p
LEFT JOIN app.medical_doctors d ON p.doctor_id = d.id LEFT JOIN app.medical_doctors d ON p.doctor_id = d.id
WHERE p.person_id = @personId WHERE {personFilter}
ORDER BY p.medication_name", ORDER BY p.medication_name",
reader => new MedicalPrescription reader => new MedicalPrescription
{ {
@@ -611,7 +751,7 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment,
DoctorName = reader.IsDBNull(13) ? null : reader.GetString(13), DoctorName = reader.IsDBNull(13) ? null : reader.GetString(13),
LastPickupDate = reader.IsDBNull(14) ? null : reader.GetDateTime(14) LastPickupDate = reader.IsDBNull(14) ? null : reader.GetDateTime(14)
}, },
new { personId }); new { personId = personId ?? 0L, accessUserId = accessUserId ?? 0L });
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -873,12 +1013,13 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment,
} }
} }
public async Task<MedicalDoctor?> FindOrCreateDoctorByNameAsync(string doctorName) public async Task<MedicalDoctor?> FindOrCreateDoctorByNameAsync(long personId, string doctorName, MedicalAiService aiService)
{ {
try try
{ {
// Step 1: Exact match
var existing = await db.ExecuteReaderAsync( var existing = await db.ExecuteReaderAsync(
"SELECT id, name, specialty, phone, address, notes, created_at, updated_at FROM app.medical_doctors WHERE LOWER(name) = LOWER(@name) LIMIT 1", "SELECT id, name, specialty, phone, address, notes, created_at, updated_at FROM app.medical_doctors WHERE person_id = @personId AND LOWER(name) = LOWER(@name) LIMIT 1",
reader => new MedicalDoctor reader => new MedicalDoctor
{ {
Id = reader.GetInt64(0), Id = reader.GetInt64(0),
@@ -890,15 +1031,29 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment,
CreatedAt = reader.GetDateTime(6), CreatedAt = reader.GetDateTime(6),
UpdatedAt = reader.GetDateTime(7) UpdatedAt = reader.GetDateTime(7)
}, },
new { name = doctorName }); new { personId, name = doctorName });
if (existing != null) return existing; if (existing != null) return existing;
return await CreateDoctorAsync(doctorName); // Step 2: AI fuzzy match
var allDoctors = await GetDoctorsAsync(personId);
if (allDoctors.Count > 0)
{
var existingNames = allDoctors.Select(d => d.Name).ToList();
var matchedName = await aiService.FuzzyMatchDoctorAsync(doctorName, existingNames);
if (matchedName != null)
{
var matched = allDoctors.FirstOrDefault(d => string.Equals(d.Name, matchedName, StringComparison.OrdinalIgnoreCase));
if (matched != null) return matched;
}
}
// Step 3: Create new
return await CreateDoctorAsync(personId, doctorName);
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Failed to find or create doctor by name \"{DoctorName}\"", doctorName); logger.LogError(ex, "Failed to find or create doctor by name \"{DoctorName}\" for person {PersonId}", doctorName, personId);
return null; return null;
} }
} }
@@ -915,7 +1070,7 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment,
var docName = rxInfo.DoctorName ?? doctorName; var docName = rxInfo.DoctorName ?? doctorName;
if (!string.IsNullOrWhiteSpace(docName)) if (!string.IsNullOrWhiteSpace(docName))
{ {
var doctor = await FindOrCreateDoctorByNameAsync(docName.Trim()); var doctor = await FindOrCreateDoctorByNameAsync(personId, docName.Trim(), aiService);
doctorId = doctor?.Id; doctorId = doctor?.Id;
} }
@@ -1081,18 +1236,22 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment,
// --- Billing Providers --- // --- Billing Providers ---
public async Task<List<MedicalBillingProvider>> GetProvidersAsync(long personId) public async Task<List<MedicalBillingProvider>> GetProvidersAsync(long? personId = null, long? accessUserId = null)
{ {
try try
{ {
var personFilter = personId.HasValue
? "p.person_id = @personId"
: "p.person_id IN (SELECT person_id FROM app.medical_people_access WHERE user_id = @accessUserId)";
return await db.ExecuteListReaderAsync( return await db.ExecuteListReaderAsync(
@"SELECT p.id, p.name, p.person_id, p.notes, p.created_at, p.updated_at, $@"SELECT p.id, p.name, p.person_id, p.notes, p.created_at, p.updated_at,
COALESCE(SUM(b.total_amount), 0) AS total_charged, COALESCE(SUM(b.total_amount), 0) AS total_charged,
COALESCE((SELECT SUM(pp.amount) FROM app.medical_provider_payments pp WHERE pp.provider_id = p.id), 0) AS total_paid, COALESCE((SELECT SUM(pp.amount) FROM app.medical_provider_payments pp WHERE pp.provider_id = p.id), 0) AS total_paid,
COUNT(DISTINCT b.id) AS bill_count COUNT(DISTINCT b.id) AS bill_count
FROM app.medical_billing_providers p FROM app.medical_billing_providers p
LEFT JOIN app.medical_bills b ON b.provider_id = p.id LEFT JOIN app.medical_bills b ON b.provider_id = p.id
WHERE p.person_id = @personId WHERE {personFilter}
GROUP BY p.id, p.name, p.person_id, p.notes, p.created_at, p.updated_at GROUP BY p.id, p.name, p.person_id, p.notes, p.created_at, p.updated_at
ORDER BY p.name", ORDER BY p.name",
reader => new MedicalBillingProvider reader => new MedicalBillingProvider
@@ -1107,7 +1266,7 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment,
TotalPaid = reader.GetDecimal(7), TotalPaid = reader.GetDecimal(7),
BillCount = reader.GetInt32(8) BillCount = reader.GetInt32(8)
}, },
new { personId }); new { personId = personId ?? 0L, accessUserId = accessUserId ?? 0L });
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -1363,10 +1522,13 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment,
// --- Bills --- // --- Bills ---
public async Task<List<MedicalBill>> GetBillsAsync(long personId, long? providerId = null) public async Task<List<MedicalBill>> GetBillsAsync(long? personId = null, long? providerId = null, long? accessUserId = null)
{ {
try try
{ {
var personFilter = personId.HasValue
? "b.person_id = @personId"
: "b.person_id IN (SELECT person_id FROM app.medical_people_access WHERE user_id = @accessUserId)";
var providerFilter = providerId.HasValue ? "AND b.provider_id = @providerId" : ""; var providerFilter = providerId.HasValue ? "AND b.provider_id = @providerId" : "";
var query = $@"SELECT b.id, b.person_id, b.total_amount, b.summary, b.category, b.bill_date, b.doctor_id, b.provider_id, b.source, b.created_at, b.updated_at, var query = $@"SELECT b.id, b.person_id, b.total_amount, b.summary, b.category, b.bill_date, b.doctor_id, b.provider_id, b.source, b.created_at, b.updated_at,
@@ -1379,10 +1541,10 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment,
FROM app.medical_bills b FROM app.medical_bills b
LEFT JOIN app.medical_doctors d ON b.doctor_id = d.id LEFT JOIN app.medical_doctors d ON b.doctor_id = d.id
LEFT JOIN app.medical_billing_providers bp ON b.provider_id = bp.id LEFT JOIN app.medical_billing_providers bp ON b.provider_id = bp.id
WHERE b.person_id = @personId {providerFilter} WHERE {personFilter} {providerFilter}
ORDER BY b.bill_date DESC NULLS LAST, b.created_at DESC"; ORDER BY b.bill_date DESC NULLS LAST, b.created_at DESC";
return await db.ExecuteListReaderAsync(query, MapBill, new { personId, providerId }); return await db.ExecuteListReaderAsync(query, MapBill, new { personId = personId ?? 0L, accessUserId = accessUserId ?? 0L, providerId });
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -1502,26 +1664,34 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment,
// --- Bill Summary --- // --- Bill Summary ---
public async Task<BillSummary> GetBillSummaryAsync(long personId) public async Task<BillSummary> GetBillSummaryAsync(long? personId = null, long? accessUserId = null)
{ {
try try
{ {
var personFilter = personId.HasValue
? "b.person_id = @personId"
: "b.person_id IN (SELECT person_id FROM app.medical_people_access WHERE user_id = @accessUserId)";
var providerPersonFilter = personId.HasValue
? "prov.person_id = @personId"
: "prov.person_id IN (SELECT person_id FROM app.medical_people_access WHERE user_id = @accessUserId)";
var filterParams = new { personId = personId ?? 0L, accessUserId = accessUserId ?? 0L };
var totals = await db.ExecuteReaderAsync( var totals = await db.ExecuteReaderAsync(
@"SELECT COALESCE(SUM(b.total_amount), 0), $@"SELECT COALESCE(SUM(b.total_amount), 0),
COALESCE((SELECT SUM(pp.amount) FROM app.medical_provider_payments pp COALESCE((SELECT SUM(pp.amount) FROM app.medical_provider_payments pp
JOIN app.medical_billing_providers prov ON pp.provider_id = prov.id JOIN app.medical_billing_providers prov ON pp.provider_id = prov.id
WHERE prov.person_id = @personId), 0) WHERE {providerPersonFilter}), 0)
FROM app.medical_bills b FROM app.medical_bills b
WHERE b.person_id = @personId", WHERE {personFilter}",
reader => new { Charged = reader.GetDecimal(0), TotalPaid = reader.GetDecimal(1) }, reader => new { Charged = reader.GetDecimal(0), TotalPaid = reader.GetDecimal(1) },
new { personId }); filterParams);
var byYear = await db.ExecuteListReaderAsync( var byYear = await db.ExecuteListReaderAsync(
@"SELECT EXTRACT(YEAR FROM COALESCE(b.bill_date, b.created_at))::int AS year, $@"SELECT EXTRACT(YEAR FROM COALESCE(b.bill_date, b.created_at))::int AS year,
SUM(b.total_amount), SUM(b.total_amount),
COUNT(b.id) COUNT(b.id)
FROM app.medical_bills b FROM app.medical_bills b
WHERE b.person_id = @personId WHERE {personFilter}
GROUP BY EXTRACT(YEAR FROM COALESCE(b.bill_date, b.created_at))::int GROUP BY EXTRACT(YEAR FROM COALESCE(b.bill_date, b.created_at))::int
ORDER BY year DESC", ORDER BY year DESC",
reader => new YearBreakdown reader => new YearBreakdown
@@ -1530,15 +1700,15 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment,
Total = reader.GetDecimal(1), Total = reader.GetDecimal(1),
Count = reader.GetInt32(2) Count = reader.GetInt32(2)
}, },
new { personId }); filterParams);
var byProvider = await db.ExecuteListReaderAsync( var byProvider = await db.ExecuteListReaderAsync(
@"SELECT COALESCE(prov.name, 'Unassigned'), $@"SELECT COALESCE(prov.name, 'Unassigned'),
COALESCE(SUM(b.total_amount), 0), COALESCE(SUM(b.total_amount), 0),
COUNT(b.id) COUNT(b.id)
FROM app.medical_bills b FROM app.medical_bills b
LEFT JOIN app.medical_billing_providers prov ON b.provider_id = prov.id LEFT JOIN app.medical_billing_providers prov ON b.provider_id = prov.id
WHERE b.person_id = @personId WHERE {personFilter}
GROUP BY COALESCE(prov.name, 'Unassigned') GROUP BY COALESCE(prov.name, 'Unassigned')
ORDER BY COALESCE(SUM(b.total_amount), 0) DESC", ORDER BY COALESCE(SUM(b.total_amount), 0) DESC",
reader => new ProviderBreakdown reader => new ProviderBreakdown
@@ -1547,7 +1717,7 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment,
Total = reader.GetDecimal(1), Total = reader.GetDecimal(1),
Count = reader.GetInt32(2) Count = reader.GetInt32(2)
}, },
new { personId }); filterParams);
var charged = totals?.Charged ?? 0; var charged = totals?.Charged ?? 0;
var totalPaid = totals?.TotalPaid ?? 0; var totalPaid = totals?.TotalPaid ?? 0;
@@ -2167,30 +2337,34 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment,
// --- Timeline --- // --- Timeline ---
public async Task<List<TimelineEvent>> GetTimelineAsync(long personId, int offset = 0, int limit = 100) public async Task<List<TimelineEvent>> GetTimelineAsync(long? personId = null, long? accessUserId = null, int offset = 0, int limit = 100)
{ {
try try
{ {
var personFilter = personId.HasValue
? "= @personId"
: "IN (SELECT person_id FROM app.medical_people_access WHERE user_id = @accessUserId)";
return await db.ExecuteListReaderAsync( return await db.ExecuteListReaderAsync(
@"SELECT event_type, id, label, detail, sub_type, event_date, doctor_id, created_at $@"SELECT event_type, id, person_id, label, detail, sub_type, event_date, doctor_id, created_at
FROM ( FROM (
SELECT 'document' AS event_type, d.id, COALESCE(d.title, d.file_name) AS label, d.description AS detail, SELECT 'document' AS event_type, d.id, d.person_id, COALESCE(d.title, d.file_name) AS label, d.description AS detail,
d.classification AS sub_type, d.document_date AS event_date, d.doctor_id, d.created_at d.classification AS sub_type, d.document_date AS event_date, d.doctor_id, d.created_at
FROM app.medical_documents d WHERE d.person_id = @personId FROM app.medical_documents d WHERE d.person_id {personFilter}
UNION ALL UNION ALL
SELECT 'condition', c.id, c.name, c.notes, SELECT 'condition', c.id, c.person_id, c.name, c.notes,
CASE WHEN c.is_active THEN 'active' ELSE 'resolved' END, c.diagnosed_date, NULL, c.created_at CASE WHEN c.is_active THEN 'active' ELSE 'resolved' END, c.diagnosed_date, NULL, c.created_at
FROM app.medical_conditions c WHERE c.person_id = @personId FROM app.medical_conditions c WHERE c.person_id {personFilter}
UNION ALL UNION ALL
SELECT 'prescription', p.id, p.medication_name, CONCAT_WS(' - ', p.dosage, p.frequency), SELECT 'prescription', p.id, p.person_id, p.medication_name, CONCAT_WS(' - ', p.dosage, p.frequency),
CASE WHEN p.is_active THEN 'active' ELSE 'ended' END, p.start_date, p.doctor_id, p.created_at CASE WHEN p.is_active THEN 'active' ELSE 'ended' END, p.start_date, p.doctor_id, p.created_at
FROM app.medical_prescriptions p WHERE p.person_id = @personId FROM app.medical_prescriptions p WHERE p.person_id {personFilter}
UNION ALL UNION ALL
SELECT 'bill', b.id, b.summary, bp.name, SELECT 'bill', b.id, b.person_id, b.summary, bp.name,
b.category, b.bill_date, b.doctor_id, b.created_at b.category, b.bill_date, b.doctor_id, b.created_at
FROM app.medical_bills b FROM app.medical_bills b
LEFT JOIN app.medical_billing_providers bp ON b.provider_id = bp.id LEFT JOIN app.medical_billing_providers bp ON b.provider_id = bp.id
WHERE b.person_id = @personId WHERE b.person_id {personFilter}
) AS timeline ) AS timeline
ORDER BY COALESCE(event_date, created_at) DESC ORDER BY COALESCE(event_date, created_at) DESC
LIMIT @limit OFFSET @offset", LIMIT @limit OFFSET @offset",
@@ -2198,14 +2372,15 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment,
{ {
EventType = reader.GetString(0), EventType = reader.GetString(0),
Id = reader.GetInt64(1), Id = reader.GetInt64(1),
Label = reader.IsDBNull(2) ? null : reader.GetString(2), PersonId = reader.GetInt64(2),
Detail = reader.IsDBNull(3) ? null : reader.GetString(3), Label = reader.IsDBNull(3) ? null : reader.GetString(3),
SubType = reader.IsDBNull(4) ? null : reader.GetString(4), Detail = reader.IsDBNull(4) ? null : reader.GetString(4),
EventDate = reader.IsDBNull(5) ? null : reader.GetDateTime(5), SubType = reader.IsDBNull(5) ? null : reader.GetString(5),
DoctorId = reader.IsDBNull(6) ? null : reader.GetInt64(6), EventDate = reader.IsDBNull(6) ? null : reader.GetDateTime(6),
CreatedAt = reader.GetDateTime(7) DoctorId = reader.IsDBNull(7) ? null : reader.GetInt64(7),
CreatedAt = reader.GetDateTime(8)
}, },
new { personId, limit, offset }); new { personId = personId ?? 0L, accessUserId = accessUserId ?? 0L, limit, offset });
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -0,0 +1,21 @@
namespace Media.JoshHeaps.Net.Services;
public sealed class SsoClientConfig
{
public string ClientId { get; set; } = string.Empty;
public string ClientSecretHash { get; set; } = string.Empty;
public List<string> RedirectUris { get; set; } = [];
public string Name { get; set; } = string.Empty;
public bool AllowsRedirectUri(string uri) =>
RedirectUris.Any(r => string.Equals(r, uri, StringComparison.Ordinal));
}
public static class SsoClientRegistry
{
public static SsoClientConfig? Find(IConfiguration config, string clientId)
{
var clients = config.GetSection("Sso:Clients").Get<List<SsoClientConfig>>() ?? [];
return clients.FirstOrDefault(c => string.Equals(c.ClientId, clientId, StringComparison.Ordinal));
}
}
@@ -0,0 +1,43 @@
using System.Text.Json;
using Media.JoshHeaps.Net.Models;
namespace Media.JoshHeaps.Net.Services;
public class ThemeService(DbExecutor db)
{
public async Task<UserThemeOverrides?> GetUserThemeAsync(long userId)
{
return await db.ExecuteReaderAsync<UserThemeOverrides?>(
@"SELECT id, user_id, base_theme, color_overrides::text, created_at, updated_at
FROM app.user_theme_overrides
WHERE user_id = @userId",
reader =>
{
if (!reader.Read()) return null;
var overridesJson = reader.GetString(3);
return new UserThemeOverrides
{
Id = reader.GetInt64(0),
UserId = reader.GetInt64(1),
BaseTheme = reader.GetString(2),
ColorOverrides = JsonSerializer.Deserialize<Dictionary<string, string>>(overridesJson) ?? new(),
CreatedAt = reader.GetDateTime(4),
UpdatedAt = reader.GetDateTime(5)
};
},
new { userId });
}
public async Task SaveUserThemeAsync(long userId, string baseTheme, Dictionary<string, string> colorOverrides)
{
var overridesJson = JsonSerializer.Serialize(colorOverrides);
await db.ExecuteNonQueryAsync(
@"INSERT INTO app.user_theme_overrides (user_id, base_theme, color_overrides, created_at, updated_at)
VALUES (@userId, @baseTheme, @overridesJson::jsonb, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (user_id) DO UPDATE
SET base_theme = @baseTheme,
color_overrides = @overridesJson::jsonb,
updated_at = CURRENT_TIMESTAMP",
new { userId, baseTheme, overridesJson });
}
}
+15
View File
@@ -20,6 +20,21 @@
"FromName": "Media App", "FromName": "Media App",
"EnableSsl": "true" "EnableSsl": "true"
}, },
"BlogCache": {
"InvalidateUrl": "https://joshheaps.net/api/blog/invalidate",
"InvalidateKey": "CHANGE_ME"
},
"Sso": {
"CodeLifetimeSeconds": 60,
"Clients": [
{
"ClientId": "ai",
"ClientSecretHash": "SET_VIA_USER_SECRETS",
"RedirectUris": [ "https://ai.joshheaps.net/auth/callback" ],
"Name": "AI JoshHeaps"
}
]
},
"FileUpload": { "FileUpload": {
"MaxFileSizeMB": 10, "MaxFileSizeMB": 10,
"AllowedImageTypes": ["image/jpeg", "image/png", "image/gif", "image/webp"], "AllowedImageTypes": ["image/jpeg", "image/png", "image/gif", "image/webp"],
+23
View File
@@ -152,6 +152,29 @@
display: block; display: block;
} }
.form-options {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.form-options .checkbox-wrapper {
margin-bottom: 0;
}
.forgot-password-link {
color: var(--text-secondary);
font-size: 13px;
text-decoration: none;
transition: color 0.2s ease;
}
.forgot-password-link:hover {
color: var(--accent-primary);
text-decoration: underline;
}
.checkbox-wrapper { .checkbox-wrapper {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -0,0 +1,281 @@
.dashboard-container {
max-width: 1200px;
margin: 0 auto;
padding: 40px 20px;
}
.welcome-section {
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
color: var(--text-primary);
padding: 32px 40px;
border-radius: 8px;
margin-bottom: 32px;
display: flex;
justify-content: space-between;
align-items: center;
}
.welcome-left {
display: flex;
align-items: center;
gap: 16px;
}
.back-button {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 8px;
color: var(--text-secondary);
text-decoration: none;
transition: all 0.2s ease;
flex-shrink: 0;
}
.back-button:hover {
background: var(--bg-hover);
border-color: var(--accent-primary);
color: var(--accent-primary);
transform: translateX(-2px);
}
.back-button svg {
width: 20px;
height: 20px;
}
.welcome-section h1 {
margin: 0;
font-size: 28px;
font-weight: 600;
letter-spacing: -0.5px;
}
.quick-actions {
display: flex;
gap: 12px;
}
.btn {
padding: 10px 20px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
text-decoration: none;
cursor: pointer;
border: 1px solid transparent;
transition: all 0.2s ease;
}
.btn-sm {
padding: 6px 12px;
font-size: 13px;
}
.btn-primary {
background: var(--accent-primary);
color: #fff;
border-color: var(--accent-primary);
}
.btn-primary:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.btn-secondary {
background: var(--bg-secondary);
color: var(--text-primary);
border-color: var(--border-primary);
}
.btn-secondary:hover {
background: var(--bg-hover);
border-color: var(--accent-primary);
}
.btn-danger {
background: var(--danger);
color: #fff;
border-color: var(--danger);
}
.btn-danger:hover {
background: var(--danger-hover);
border-color: var(--danger-hover);
}
.admin-section {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 8px;
padding: 24px;
margin-bottom: 24px;
}
.section-header {
margin-bottom: 16px;
}
.section-header h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
}
.table-container {
overflow-x: auto;
}
.admin-table {
width: 100%;
border-collapse: collapse;
}
.admin-table th,
.admin-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid var(--border-secondary);
}
.admin-table th {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-secondary);
background: var(--bg-tertiary);
}
.admin-table td {
font-size: 14px;
color: var(--text-primary);
}
.admin-table tbody tr:hover {
background: var(--bg-hover);
}
.admin-table code {
font-size: 12px;
padding: 2px 6px;
background: var(--bg-tertiary);
border-radius: 4px;
color: var(--text-secondary);
}
.empty-message {
text-align: center;
color: var(--text-secondary);
font-style: italic;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.tag-pill {
display: inline-flex;
align-items: center;
padding: 2px 8px;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: 12px;
font-size: 12px;
color: var(--text-secondary);
}
.action-cell {
display: flex;
gap: 6px;
}
/* Editor form */
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
margin-bottom: 6px;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
}
.form-input {
width: 100%;
padding: 10px 12px;
background: var(--bg-primary);
border: 1px solid var(--border-primary);
border-radius: 6px;
color: var(--text-primary);
font-size: 14px;
outline: none;
transition: border-color 0.2s ease;
box-sizing: border-box;
}
.form-input:focus {
border-color: var(--accent-primary);
}
.content-toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.upload-status {
font-size: 13px;
color: var(--text-secondary);
}
.upload-error {
color: var(--danger);
}
.upload-success {
color: var(--success, #22c55e);
}
.form-textarea {
min-height: 400px;
resize: vertical;
font-family: monospace;
line-height: 1.5;
}
.editor-actions {
display: flex;
gap: 8px;
margin-top: 20px;
}
@media (max-width: 768px) {
.welcome-section {
flex-direction: column;
gap: 16px;
padding: 20px;
align-items: flex-start;
}
.admin-table th,
.admin-table td {
padding: 8px 10px;
}
.action-cell {
flex-direction: column;
}
}
@@ -0,0 +1,250 @@
/* Breadboard Simulator - Project List Page Styles */
/* Every colour here comes from the site.css custom properties, so light mode is handled
by the [data-theme="light"] block there and this file needs no theme-specific rules.
site.css already applies `* { margin: 0; padding: 0; box-sizing: border-box }`. */
.bbp-page {
padding: 32px 0;
}
/* Header */
.bbp-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 32px;
}
.bbp-header-left {
display: flex;
align-items: center;
gap: 16px;
}
.bbp-header h1 {
font-size: 1.75rem;
font-weight: 600;
color: var(--text-primary);
}
.bbp-back {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
flex: 0 0 auto;
border: 1px solid var(--border-primary);
border-radius: 8px;
color: var(--text-secondary);
transition: all 0.2s ease;
}
.bbp-back:hover {
background: var(--bg-hover);
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.bbp-back svg {
width: 20px;
height: 20px;
}
/* Buttons */
.bbp-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 8px 16px;
font-size: 0.9rem;
font-weight: 500;
font-family: inherit;
line-height: 1.4;
border: 1px solid var(--border-primary);
border-radius: 8px;
background: var(--bg-tertiary);
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s ease;
}
.bbp-btn:hover {
background: var(--bg-hover);
border-color: var(--accent-primary);
}
.bbp-btn-primary {
background: var(--accent-primary);
border-color: var(--accent-primary);
color: var(--bg-primary);
}
.bbp-btn-primary:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.bbp-btn-danger {
color: var(--danger);
}
.bbp-btn-danger:hover {
background: var(--danger);
border-color: var(--danger);
color: var(--bg-primary);
}
/* Error banner. Hidden by default; the script adds .is-visible to show it. Both halves are
defined right here, so visibility can never depend on a class defined somewhere else (or,
as it turned out, nowhere at all). Keep `display: none` below as the hidden baseline; if you
need a different VISIBLE display mode, change it on .bbp-error.is-visible instead. */
.bbp-error {
display: none;
padding: 12px 16px;
margin-bottom: 24px;
border: 1px solid var(--danger);
border-radius: 8px;
background: var(--bg-secondary);
color: var(--danger);
white-space: pre-line;
}
.bbp-error.is-visible {
display: block;
}
/* Create form */
.bbp-card {
padding: 20px;
margin-bottom: 32px;
border: 1px solid var(--border-primary);
border-radius: 12px;
background: var(--bg-secondary);
}
.bbp-card-title {
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-secondary);
margin-bottom: 16px;
}
.bbp-create-form {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 12px;
}
.bbp-field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 200px;
}
.bbp-field-grow {
flex: 1 1 320px;
}
.bbp-field label {
font-size: 0.85rem;
color: var(--text-secondary);
}
.bbp-input {
padding: 8px 12px;
font-size: 0.95rem;
font-family: inherit;
border: 1px solid var(--border-primary);
border-radius: 8px;
background: var(--bg-primary);
color: var(--text-primary);
transition: border-color 0.2s ease;
}
.bbp-input:focus {
outline: none;
border-color: var(--accent-primary);
}
/* Project list */
.bbp-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.bbp-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px;
border: 1px solid var(--border-primary);
border-radius: 8px;
background: var(--bg-tertiary);
transition: all 0.2s ease;
}
.bbp-item:hover {
border-color: var(--accent-primary);
background: var(--bg-hover);
}
.bbp-item-main {
min-width: 0;
flex: 1;
}
.bbp-item-name {
font-weight: 600;
color: var(--accent-primary);
}
.bbp-item-name:hover {
color: var(--accent-hover);
text-decoration: underline;
}
.bbp-item-description,
.bbp-item-meta {
font-size: 0.85rem;
color: var(--text-secondary);
margin-top: 4px;
}
.bbp-item-actions {
display: flex;
gap: 8px;
flex: 0 0 auto;
}
.bbp-empty {
padding: 32px;
text-align: center;
border: 1px dashed var(--border-primary);
border-radius: 12px;
color: var(--text-secondary);
}
@media (max-width: 640px) {
.bbp-item {
flex-direction: column;
align-items: stretch;
}
.bbp-item-actions {
justify-content: flex-end;
}
}
@@ -0,0 +1,610 @@
/* Breadboard simulator.
*
* Theming follows site.css: the DARK palette is the default on :root, and
* [data-theme="light"] overrides it. Do not add dark-only blocks.
*
* The --bb-* custom properties below are read by the canvas renderer through
* getComputedStyle - canvas cannot inherit CSS, so every colour the renderer draws is
* defined here and nowhere else. Changing the board's look is a stylesheet edit.
*/
:root {
/* Canvas surround */
--bb-canvas-bg: #14161a;
--bb-grid: #1e2126;
/* Board body - a dark-room breadboard reads slightly desaturated */
--bb-board-face: #d9d6cb;
--bb-board-edge: #a8a496;
--bb-board-bevel: #efece3;
--bb-channel: #c3bfb2;
--bb-channel-edge: #a29e90;
--bb-hole: #26282b;
--bb-hole-rim: #b3afa2;
--bb-silk: #7d7869;
--bb-silk-strong: #56524a;
--bb-rail-plus: #c8483f;
--bb-rail-minus: #3f63c8;
/* Interaction */
--bb-hover: #3fb950;
--bb-selection: #58a6ff;
--bb-ghost: #58a6ff;
--bb-invalid: #f85149;
--bb-wire-shadow: rgba(0, 0, 0, 0.45);
/* Parts */
--bb-chip-body: #24242a;
--bb-chip-label: #d9d9de;
--bb-chip-pin: #c2c2c8;
--bb-resistor-body: #d6c298;
--bb-resistor-lead: #a9a9ad;
--bb-diode-body: #9aa7b4;
--bb-diode-band: #1c1c20;
--bb-transistor-body: #1f1f24;
--bb-button-body: #34343a;
--bb-button-cap: #c9553f;
--bb-button-cap-down: #8d3a2b;
--bb-dip-body: #2b4785;
--bb-dip-switch-on: #f4f4f6;
--bb-dip-switch-off: #83838a;
--bb-supply-body: #1f2933;
--bb-supply-text: #e6edf3;
--bb-burned: #4a4a4a;
/* Logic levels */
--bb-level-low: #3b6ea5;
--bb-level-high: #e0483d;
--bb-level-hiz: #7d7d85;
--bb-level-weak-low: #4f7fa8;
--bb-level-weak-high: #d98a4a;
--bb-level-contention: #ffcc00;
}
[data-theme="light"] {
--bb-canvas-bg: #eceff3;
--bb-grid: #dfe3e9;
--bb-board-face: #f2f0e9;
--bb-board-edge: #c9c5b8;
--bb-board-bevel: #ffffff;
--bb-channel: #ddd9cf;
--bb-channel-edge: #bdb9ac;
--bb-hole: #3a3a3a;
--bb-hole-rim: #cfcbbe;
--bb-silk: #97917f;
--bb-silk-strong: #625d52;
--bb-rail-plus: #d24b4b;
--bb-rail-minus: #4b6fd2;
--bb-wire-shadow: rgba(0, 0, 0, 0.22);
--bb-chip-body: #2b2b30;
--bb-transistor-body: #2b2b30;
--bb-supply-body: #26303a;
}
/* --- Page shell -------------------------------------------------------------
* The canvas needs a box with a definite height to measure itself against, which
* nothing above it provides - hence the explicit 100vh.
*
* No breakout is needed and none is wanted. _Layout.cshtml links only site.css: neither
* Bootstrap's CSS nor the CSS-isolation bundle (Media.JoshHeaps.Net.styles.css) is ever
* loaded, so `.container` applies no max-width or padding, and the empty <footer>
* reserves no space. Verified, not assumed. Consequently:
* - `width: 100%` rather than `100vw`, because 100vw includes the vertical scrollbar
* and would produce a horizontal one for no benefit,
* - full `100vh` rather than subtracting a footer that does not occupy any height.
*/
.bb-fullbleed {
width: 100%;
height: 100vh;
min-height: 480px;
overflow: hidden;
}
#breadboard-editor {
height: 100%;
display: flex;
flex-direction: column;
background: var(--bb-canvas-bg);
color: var(--text-primary);
font-size: 0.875rem;
}
.bb-editor-shell {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.bb-workspace {
display: flex;
flex: 1 1 auto;
min-height: 0;
}
/* --- Toolbar --------------------------------------------------------------- */
.bb-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1.25rem;
padding: 0.5rem 0.875rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-primary);
flex: 0 0 auto;
}
.bb-toolbar-group {
display: flex;
align-items: center;
gap: 0.4rem;
}
.bb-group-view {
margin-left: auto;
}
.bb-title {
font-size: 1rem;
font-weight: 600;
margin: 0 0.5rem 0 0;
max-width: 22ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bb-save-state {
font-size: 0.8rem;
color: var(--text-secondary);
min-width: 9ch;
}
.bb-save-dirty { color: var(--accent-primary); }
.bb-save-error { color: var(--danger); }
.bb-save-clean { color: var(--success); }
.bb-sim-state {
font-size: 0.8rem;
color: var(--text-secondary);
min-width: 12ch;
}
.bb-sim-running { color: var(--success); }
.bb-sim-halted { color: var(--danger); }
.bb-speed {
width: 8rem;
accent-color: var(--accent-primary);
}
.bb-speed-label,
.bb-zoom-label {
font-size: 0.78rem;
color: var(--text-secondary);
min-width: 8ch;
text-align: right;
font-variant-numeric: tabular-nums;
}
.bb-zoom-label { min-width: 5ch; text-align: center; }
/* --- Buttons --------------------------------------------------------------- */
.bb-btn {
font: inherit;
font-size: 0.8rem;
padding: 0.3rem 0.7rem;
border-radius: 6px;
border: 1px solid var(--border-primary);
background: var(--bg-tertiary);
color: var(--text-primary);
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.bb-btn:hover:not(:disabled) {
background: var(--bg-hover);
border-color: var(--accent-primary);
}
.bb-btn:disabled {
opacity: 0.45;
cursor: default;
}
.bb-btn:focus-visible,
.bb-tool:focus-visible,
.bb-swatch:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 2px;
}
.bb-btn-primary {
background: var(--accent-primary);
border-color: var(--accent-primary);
color: #fff;
}
.bb-btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
border-color: var(--accent-hover);
color: #fff;
}
.bb-btn-run { color: var(--success); }
.bb-btn-danger { color: var(--danger); border-color: var(--border-primary); }
.bb-btn-danger:hover:not(:disabled) { border-color: var(--danger); background: var(--bg-hover); }
.bb-btn-ghost { background: transparent; border-color: transparent; }
.bb-btn-small { font-size: 0.72rem; padding: 0.15rem 0.45rem; }
.bb-btn-icon {
width: 1.9rem;
padding: 0.25rem 0;
text-align: center;
font-size: 0.95rem;
line-height: 1;
}
/* --- Panel hosts ----------------------------------------------------------- */
.bb-toolbar-host {
flex: 0 0 auto;
}
.bb-palette-host,
.bb-props-host {
flex: 0 0 auto;
min-height: 0;
overflow: hidden;
display: flex;
}
/* Scroll containers for the two panel bodies. min-height:0 is required: without it a
flex child refuses to shrink below its content and the panel grows instead of
scrolling. */
.bb-props-body,
.bb-boards-body {
display: flex;
flex-direction: column;
min-height: 0;
}
/* --- Palette --------------------------------------------------------------- */
.bb-palette,
.bb-props {
width: 13.5rem;
padding: 0.75rem;
overflow-y: auto;
background: var(--bg-secondary);
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.bb-palette { border-right: 1px solid var(--border-primary); }
.bb-props { border-left: 1px solid var(--border-primary); width: 15rem; }
.bb-palette-heading,
.bb-props-heading {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-secondary);
margin: 0 0 0.35rem;
font-weight: 600;
}
.bb-palette-section { display: flex; flex-direction: column; }
.bb-palette-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.3rem;
}
.bb-tool {
font: inherit;
font-size: 0.75rem;
padding: 0.4rem 0.3rem;
border-radius: 6px;
border: 1px solid var(--border-primary);
background: var(--bg-tertiary);
color: var(--text-primary);
cursor: pointer;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bb-tool:hover { background: var(--bg-hover); }
.bb-tool.is-active {
background: var(--accent-primary);
border-color: var(--accent-primary);
color: #fff;
}
.bb-tool-component { font-family: ui-monospace, "SF Mono", Consolas, monospace; }
.bb-swatches {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-top: 0.5rem;
}
.bb-swatch {
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
border: 2px solid transparent;
box-shadow: 0 0 0 1px var(--border-primary);
cursor: pointer;
padding: 0;
}
.bb-swatch.is-active {
border-color: var(--text-primary);
transform: scale(1.15);
}
.bb-palette-hint {
font-size: 0.7rem;
line-height: 1.5;
color: var(--text-secondary);
margin: auto 0 0;
}
/* --- Canvas ---------------------------------------------------------------- */
.bb-canvas-container {
position: relative;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
overflow: hidden;
background:
radial-gradient(circle at 1px 1px, var(--bb-grid) 1px, transparent 0) 0 0 / 24px 24px,
var(--bb-canvas-bg);
}
.bb-layer {
position: absolute;
inset: 0;
display: block;
}
.bb-layer-overlay:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: -2px;
}
/* --- Properties ------------------------------------------------------------ */
.bb-props-title {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem;
}
.bb-props-uid {
font-size: 0.68rem;
color: var(--text-secondary);
font-family: ui-monospace, "SF Mono", Consolas, monospace;
}
.bb-props-description,
.bb-props-meta,
.bb-props-empty {
font-size: 0.72rem;
line-height: 1.5;
color: var(--text-secondary);
margin: 0.35rem 0;
}
.bb-props-warning {
font-size: 0.72rem;
line-height: 1.5;
color: var(--danger);
margin: 0.35rem 0;
}
.bb-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin: 0.6rem 0;
}
.bb-field-label {
font-size: 0.7rem;
color: var(--text-secondary);
}
.bb-field-stack { display: flex; flex-direction: column; gap: 0.35rem; }
.bb-input {
font: inherit;
font-size: 0.8rem;
padding: 0.3rem 0.45rem;
border-radius: 6px;
border: 1px solid var(--border-primary);
background: var(--bg-primary);
color: var(--text-primary);
width: 100%;
}
.bb-input:focus-visible {
outline: none;
border-color: var(--accent-primary);
}
.bb-presets { display: flex; flex-wrap: wrap; gap: 0.2rem; }
.bb-chip-btn {
font: inherit;
font-size: 0.66rem;
padding: 0.12rem 0.35rem;
border-radius: 999px;
border: 1px solid var(--border-primary);
background: var(--bg-tertiary);
color: var(--text-secondary);
cursor: pointer;
}
.bb-chip-btn:hover { color: var(--text-primary); border-color: var(--accent-primary); }
.bb-switch-row { display: flex; flex-wrap: wrap; gap: 0.2rem; }
.bb-switch-toggle {
font: inherit;
font-size: 0.68rem;
width: 1.6rem;
height: 1.6rem;
border-radius: 5px;
border: 1px solid var(--border-primary);
background: var(--bg-tertiary);
color: var(--text-secondary);
cursor: pointer;
}
.bb-switch-toggle.is-on {
background: var(--success);
border-color: var(--success);
color: #fff;
}
.bb-props-actions {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.75rem;
}
.bb-board-row {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0;
border-bottom: 1px solid var(--border-secondary);
font-size: 0.74rem;
}
.bb-board-name { font-family: ui-monospace, "SF Mono", Consolas, monospace; }
.bb-board-count { color: var(--text-secondary); margin-right: auto; }
/* --- Status strip ---------------------------------------------------------- */
/* Floats over the bottom of the canvas, NOT stacked below it. Sharing the column
* meant every message that appeared or timed out resized the canvas, which reallocated
* the backing stores and forced a full repaint. */
.bb-status-host {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 3;
/* The strip is often empty; the canvas under it must stay clickable. */
pointer-events: none;
}
.bb-status {
display: flex;
flex-direction: column;
max-height: 11rem;
overflow-y: auto;
background: var(--bg-secondary);
pointer-events: auto;
}
/* Border on the groups rather than the panel: both collapse when empty, so an idle
* editor shows no stray rule across the canvas. */
.bb-status-messages,
.bb-status-warnings { border-top: 1px solid var(--border-primary); }
.bb-status-messages:empty,
.bb-status-warnings:not(.is-visible) { display: none; }
.bb-message {
padding: 0.35rem 0.875rem;
font-size: 0.76rem;
border-left: 3px solid transparent;
animation: bb-fade-in 0.15s ease;
}
.bb-message-info { color: var(--text-secondary); border-left-color: var(--border-primary); }
.bb-message-warn { color: var(--text-primary); border-left-color: #e8a33d; }
.bb-message-error { color: var(--danger); border-left-color: var(--danger); }
@keyframes bb-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.bb-message { animation: none; }
.bb-swatch.is-active { transform: none; }
.bb-btn { transition: none; }
}
.bb-status-warnings-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.3rem 0.875rem;
border-bottom: 1px solid var(--border-secondary);
}
.bb-status-warnings-title {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
margin-right: auto;
}
.bb-warning {
display: flex;
align-items: baseline;
gap: 0.5rem;
padding: 0.3rem 0.875rem;
font-size: 0.76rem;
border-left: 3px solid transparent;
}
.bb-warning-warn { border-left-color: #e8a33d; }
.bb-warning-error { border-left-color: var(--danger); }
.bb-warning-title { font-weight: 600; }
.bb-warning-count { color: var(--text-secondary); font-size: 0.7rem; }
.bb-warning-detail,
.bb-warning-uids {
color: var(--text-secondary);
font-size: 0.72rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bb-warning-uids { font-family: ui-monospace, "SF Mono", Consolas, monospace; }
/* --- Narrow screens -------------------------------------------------------- */
@media (max-width: 900px) {
.bb-palette, .bb-props { width: 10.5rem; padding: 0.5rem; }
.bb-palette-grid { grid-template-columns: 1fr; }
.bb-group-speed { display: none; }
}
@media (max-width: 640px) {
.bb-props-host { display: none; }
.bb-toolbar { gap: 0.6rem; }
.bb-title { max-width: 12ch; }
}
@@ -323,6 +323,39 @@
color: #fff; color: #fff;
} }
.person-pill-row {
display: flex;
align-items: center;
gap: 4px;
}
.person-pill-row .person-pill {
flex: 1;
min-width: 0;
}
.person-share-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
background: transparent;
border: 1px solid var(--border-primary);
border-radius: 50%;
color: var(--text-muted);
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
}
.person-share-btn:hover {
border-color: var(--accent-primary);
color: var(--accent-primary);
background: var(--bg-tertiary);
}
/* ======================== */ /* ======================== */
/* Form Inputs */ /* Form Inputs */
/* ======================== */ /* ======================== */
@@ -1494,6 +1527,26 @@
line-height: 1.6; line-height: 1.6;
} }
/* ======================== */
/* All-mode (read-only) */
/* ======================== */
.all-mode .add-form-toggle { display: none; }
.all-mode .add-form-collapsible { display: none; }
.person-badge {
display: inline-block;
padding: 1px 7px;
background: var(--accent-primary);
color: #fff;
border-radius: 10px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.3px;
vertical-align: middle;
margin-right: 4px;
}
/* ======================== */ /* ======================== */
/* Responsive (<=768px) */ /* Responsive (<=768px) */
/* ======================== */ /* ======================== */
+140
View File
@@ -0,0 +1,140 @@
.profile-container {
max-width: 800px;
margin: 40px auto;
padding: 0 20px;
}
.profile-section {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 8px;
padding: 24px;
margin-bottom: 20px;
}
.profile-section h2 {
margin: 0 0 20px 0;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
border-bottom: 1px solid var(--border-primary);
padding-bottom: 12px;
}
.profile-field {
margin-bottom: 16px;
}
.profile-field label {
display: block;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 4px;
font-size: 14px;
}
.profile-field-value {
color: var(--text-primary);
font-size: 15px;
}
.theme-toggle-container {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
}
.theme-toggle-label {
display: flex;
flex-direction: column;
}
.theme-toggle-label strong {
color: var(--text-primary);
font-size: 15px;
margin-bottom: 4px;
}
.theme-toggle-label span {
color: var(--text-secondary);
font-size: 13px;
}
.toggle-switch {
position: relative;
display: inline-block;
width: 50px;
height: 26px;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--bg-tertiary);
border: 1px solid var(--border-primary);
transition: 0.3s;
border-radius: 34px;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: var(--text-secondary);
transition: 0.3s;
border-radius: 50%;
}
input:checked + .toggle-slider {
background-color: var(--accent-primary);
border-color: var(--accent-primary);
}
input:checked + .toggle-slider:before {
transform: translateX(24px);
background-color: var(--bg-primary);
}
.back-link {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--accent-primary);
text-decoration: none;
font-size: 14px;
margin-bottom: 20px;
transition: color 0.2s ease;
}
.back-link:hover {
color: var(--accent-hover);
}
.customize-btn {
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: 6px;
color: var(--text-primary);
padding: 8px 16px;
font-size: 14px;
cursor: pointer;
transition: background 0.15s;
}
.customize-btn:hover {
background: var(--bg-hover);
}
@@ -0,0 +1,214 @@
#theme-customizer-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
}
#theme-customizer-modal {
background: var(--bg-primary);
border: 1px solid var(--border-primary);
border-radius: 8px;
width: 90vw;
max-width: 1200px;
height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.tc-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
border-bottom: 1px solid var(--border-primary);
flex-shrink: 0;
}
.tc-header h3 {
margin: 0;
font-size: 18px;
color: var(--text-primary);
}
.tc-close-btn {
background: none;
border: none;
font-size: 24px;
color: var(--text-secondary);
cursor: pointer;
line-height: 1;
}
.tc-close-btn:hover {
color: var(--text-primary);
}
.tc-controls {
padding: 12px 20px;
border-bottom: 1px solid var(--border-primary);
flex-shrink: 0;
}
.tc-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.tc-row:last-child {
margin-bottom: 0;
}
.tc-row label {
min-width: 110px;
font-size: 14px;
color: var(--text-secondary);
font-weight: 500;
}
.tc-row select,
.tc-row input[type="text"] {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 4px;
color: var(--text-primary);
padding: 6px 10px;
font-size: 14px;
}
.tc-row select {
flex: 1;
max-width: 280px;
}
.tc-picker-row input[type="color"] {
width: 40px;
height: 34px;
border: 1px solid var(--border-primary);
border-radius: 4px;
cursor: pointer;
padding: 2px;
background: var(--bg-secondary);
}
.tc-picker-row input[type="text"] {
width: 90px;
font-family: monospace;
}
.tc-swatches {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin: 8px 0 4px;
}
.tc-swatch {
width: 28px;
height: 28px;
border-radius: 4px;
border: 2px solid var(--border-secondary);
cursor: pointer;
transition: border-color 0.15s;
}
.tc-swatch:hover {
border-color: var(--text-secondary);
}
.tc-swatch-overridden {
border-color: var(--accent-primary);
border-width: 2px;
}
.tc-swatch-selected {
outline: 2px solid var(--accent-primary);
outline-offset: 2px;
}
.tc-btn-row {
display: flex;
gap: 10px;
margin-top: 8px;
}
.tc-btn {
padding: 8px 16px;
border-radius: 6px;
border: 1px solid var(--border-primary);
font-size: 14px;
cursor: pointer;
background: var(--bg-secondary);
color: var(--text-primary);
transition: background 0.15s;
}
.tc-btn:hover {
background: var(--bg-hover);
}
.tc-btn-save {
background: var(--accent-primary);
color: #fff;
border-color: var(--accent-primary);
}
.tc-btn-save:hover {
background: var(--accent-hover);
}
.tc-btn-reset {
margin-left: auto;
color: var(--danger);
border-color: var(--danger);
}
.tc-btn-reset:hover {
background: var(--danger);
color: #fff;
}
.tc-btn-small {
padding: 4px 10px;
font-size: 12px;
}
.tc-preview {
flex: 1;
min-height: 0;
padding: 0 20px 20px;
display: flex;
flex-direction: column;
}
.tc-preview iframe {
width: 100%;
flex: 1;
min-height: 0;
border: 1px solid var(--border-primary);
border-radius: 4px;
}
@media (max-width: 600px) {
#theme-customizer-modal {
width: 98vw;
height: 98vh;
}
.tc-row {
flex-wrap: wrap;
}
.tc-row label {
min-width: auto;
}
}
+136
View File
@@ -336,9 +336,145 @@ function initRegisterForm() {
}); });
} }
// Password reset form validation
function initPasswordResetForm() {
const form = document.getElementById('resetPasswordForm');
if (!form) return;
const passwordInput = document.getElementById('newPassword');
const confirmPasswordInput = document.getElementById('confirmPassword');
if (passwordInput) {
passwordInput.addEventListener('input', function() {
checkPasswordStrength(this.value);
if (this.value && validatePassword(this.value)) {
clearError(this);
}
if (confirmPasswordInput && confirmPasswordInput.value) {
if (confirmPasswordInput.value === this.value) {
clearError(confirmPasswordInput);
} else {
showError(confirmPasswordInput, 'Passwords do not match');
}
}
});
passwordInput.addEventListener('blur', function() {
if (!this.value) {
showError(this, 'Password is required');
} else if (!validatePassword(this.value)) {
showError(this, 'Password must be at least 8 characters');
} else {
clearError(this);
}
});
}
if (confirmPasswordInput) {
confirmPasswordInput.addEventListener('input', function() {
if (passwordInput && this.value === passwordInput.value) {
clearError(this);
}
});
confirmPasswordInput.addEventListener('blur', function() {
if (!this.value) {
showError(this, 'Please confirm your password');
} else if (passwordInput && this.value !== passwordInput.value) {
showError(this, 'Passwords do not match');
} else {
clearError(this);
}
});
}
form.addEventListener('submit', function(e) {
e.preventDefault();
let isValid = true;
if (!passwordInput.value) {
showError(passwordInput, 'Password is required');
isValid = false;
} else if (!validatePassword(passwordInput.value)) {
showError(passwordInput, 'Password must be at least 8 characters');
isValid = false;
} else {
clearError(passwordInput);
}
if (!confirmPasswordInput.value) {
showError(confirmPasswordInput, 'Please confirm your password');
isValid = false;
} else if (confirmPasswordInput.value !== passwordInput.value) {
showError(confirmPasswordInput, 'Passwords do not match');
isValid = false;
} else {
clearError(confirmPasswordInput);
}
if (isValid) {
const submitBtn = form.querySelector('button[type="submit"]');
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner"></span> Resetting...';
form.submit();
}
});
}
// Request reset form validation
function initRequestResetForm() {
const form = document.getElementById('requestResetForm');
if (!form) return;
const emailInput = document.getElementById('email');
if (emailInput) {
emailInput.addEventListener('blur', function() {
if (!this.value.trim()) {
showError(this, 'Email is required');
} else if (!validateEmail(this.value)) {
showError(this, 'Please enter a valid email address');
} else {
clearError(this);
}
});
emailInput.addEventListener('input', function() {
if (this.value.trim() && validateEmail(this.value)) {
clearError(this);
}
});
}
form.addEventListener('submit', function(e) {
e.preventDefault();
if (!emailInput.value.trim()) {
showError(emailInput, 'Email is required');
return;
}
if (!validateEmail(emailInput.value)) {
showError(emailInput, 'Please enter a valid email address');
return;
}
clearError(emailInput);
const submitBtn = form.querySelector('button[type="submit"]');
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner"></span> Sending...';
form.submit();
});
}
// Initialize on page load // Initialize on page load
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
initPasswordToggles(); initPasswordToggles();
initLoginForm(); initLoginForm();
initRegisterForm(); initRegisterForm();
initPasswordResetForm();
initRequestResetForm();
}); });
@@ -0,0 +1,195 @@
(function () {
let posts = [];
document.addEventListener('DOMContentLoaded', init);
async function init() {
await loadPosts();
document.getElementById('newPostBtn').addEventListener('click', showNewPostEditor);
document.getElementById('savePostBtn').addEventListener('click', savePost);
document.getElementById('cancelEditBtn').addEventListener('click', hideEditor);
const imageInput = document.getElementById('imageFileInput');
document.getElementById('uploadImageBtn').addEventListener('click', () => imageInput.click());
imageInput.addEventListener('change', uploadImage);
}
async function uploadImage() {
const input = document.getElementById('imageFileInput');
const status = document.getElementById('uploadStatus');
if (!input.files.length) return;
const file = input.files[0];
const formData = new FormData();
formData.append('file', file);
status.textContent = 'Uploading...';
status.className = 'upload-status';
try {
const res = await fetch('/api/blog/images', { method: 'POST', body: formData });
if (!res.ok) {
const err = await res.json();
status.textContent = err.error || 'Upload failed';
status.className = 'upload-status upload-error';
return;
}
const { url } = await res.json();
const textarea = document.getElementById('postContent');
const markdown = `![${file.name}](${url})`;
const pos = textarea.selectionStart;
const before = textarea.value.substring(0, pos);
const after = textarea.value.substring(pos);
const needsNewline = before.length > 0 && !before.endsWith('\n') ? '\n' : '';
textarea.value = before + needsNewline + markdown + '\n' + after;
textarea.focus();
textarea.selectionStart = textarea.selectionEnd = pos + needsNewline.length + markdown.length + 1;
status.textContent = 'Uploaded!';
status.className = 'upload-status upload-success';
setTimeout(() => { status.textContent = ''; }, 2000);
} catch {
status.textContent = 'Upload failed';
status.className = 'upload-status upload-error';
} finally {
input.value = '';
}
}
async function loadPosts() {
const res = await fetch('/api/blog/posts');
if (!res.ok) return;
posts = await res.json();
renderPostsTable();
}
function renderPostsTable() {
const tbody = document.getElementById('postsTableBody');
if (posts.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="empty-message">No posts yet. Create your first post!</td></tr>';
return;
}
tbody.innerHTML = posts.map(post => {
const tags = post.tags.map(t => `<span class="tag-pill">${escapeHtml(t)}</span>`).join('');
const date = new Date(post.publishedDate).toLocaleDateString();
return `<tr>
<td>${escapeHtml(post.title)}</td>
<td><code>${escapeHtml(post.slug)}</code></td>
<td><div class="tag-list">${tags}</div></td>
<td>${date}</td>
<td class="action-cell">
<button class="btn btn-secondary btn-sm" onclick="blogEditPost(${post.id})">Edit</button>
<button class="btn btn-danger btn-sm" onclick="blogDeletePost(${post.id})">Delete</button>
</td>
</tr>`;
}).join('');
}
function showNewPostEditor() {
document.getElementById('editorTitle').textContent = 'New Post';
document.getElementById('editPostId').value = '';
document.getElementById('postTitle').value = '';
document.getElementById('postSummary').value = '';
document.getElementById('postTags').value = '';
document.getElementById('postContent').value = '';
document.getElementById('postDate').value = new Date().toISOString().split('T')[0];
document.getElementById('postsList').style.display = 'none';
document.getElementById('postEditor').style.display = '';
}
function hideEditor() {
document.getElementById('postEditor').style.display = 'none';
document.getElementById('postsList').style.display = '';
}
async function savePost() {
const id = document.getElementById('editPostId').value;
const title = document.getElementById('postTitle').value.trim();
const summary = document.getElementById('postSummary').value.trim();
const tagsStr = document.getElementById('postTags').value.trim();
const markdownContent = document.getElementById('postContent').value;
const dateStr = document.getElementById('postDate').value;
if (!title) { alert('Title is required'); return; }
if (!markdownContent) { alert('Content is required'); return; }
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(t => t) : [];
const publishedDate = dateStr ? new Date(dateStr + 'T00:00:00Z').toISOString() : null;
const body = { title, summary, markdownContent, tags, publishedDate };
const url = id ? `/api/blog/posts/${id}` : '/api/blog/posts';
const method = id ? 'PUT' : 'POST';
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) {
const err = await res.json();
alert(err.error || 'Failed to save post');
return;
}
hideEditor();
await loadPosts();
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
window.blogEditPost = async function (id) {
const res = await fetch(`/api/blog/posts`);
if (!res.ok) return;
const allPosts = await res.json();
// Need markdown content, so fetch the admin-specific data
// The public API doesn't return markdownContent, so we fetch all and find by id
// Actually, we need to get it from the create/update response pattern
// For editing, we'll fetch via a dedicated admin endpoint or use stored data
// Since the public GET returns htmlContent but not markdownContent,
// let's make a specific request. But our API only has public routes returning htmlContent.
// We need the admin to get markdownContent too.
// Workaround: find in local posts array (which has htmlContent), but we need markdown.
// The cleanest fix is to have the GET endpoints also return markdownContent for admins,
// but for now let's add an admin-specific detail endpoint.
// Actually, looking at the API, the GET /posts/{slug} returns the public DTO.
// Let me use a different approach: store markdownContent in memory from the list.
// For now, we need to get the post with markdownContent.
// Let's fetch by id from the admin-aware endpoint.
const detailRes = await fetch(`/api/blog/posts/admin/${id}`);
if (!detailRes.ok) {
alert('Failed to load post for editing');
return;
}
const post = await detailRes.json();
document.getElementById('editorTitle').textContent = 'Edit Post';
document.getElementById('editPostId').value = post.id;
document.getElementById('postTitle').value = post.title;
document.getElementById('postSummary').value = post.summary || '';
document.getElementById('postTags').value = (post.tags || []).join(', ');
document.getElementById('postContent').value = post.markdownContent || '';
document.getElementById('postDate').value = new Date(post.publishedDate).toISOString().split('T')[0];
document.getElementById('postsList').style.display = 'none';
document.getElementById('postEditor').style.display = '';
};
window.blogDeletePost = async function (id) {
if (!confirm('Are you sure you want to delete this post?')) return;
const res = await fetch(`/api/blog/posts/${id}`, { method: 'DELETE' });
if (!res.ok) {
const err = await res.json();
alert(err.error || 'Failed to delete post');
return;
}
await loadPosts();
};
})();
@@ -0,0 +1,130 @@
// Server calls for breadboard projects.
//
// Every error path produces a human-readable message. The API returns a uniform
// { errors: [...] } body on 400/401/404/500, but a request that fails model binding
// before the controller runs yields ASP.NET's ProblemDetails instead, where `errors`
// is an OBJECT keyed by field - hence the Array.isArray guard.
import {
serializeCircuit,
circuitByteSize,
MAX_CIRCUIT_BYTES
} from '../shared/circuit-schema.js';
/** Thrown for any non-2xx response. `messages` is always a non-empty string array. */
export class ApiError extends Error {
constructor(status, messages) {
super(messages[0]);
this.name = 'ApiError';
this.status = status;
this.messages = messages;
}
}
async function readErrors(response) {
if (response.status === 413) {
return ['The circuit is too large to save. Remove some wires or components and try again.'];
}
let payload = null;
try {
payload = await response.json();
} catch {
payload = null;
}
if (payload && Array.isArray(payload.errors) && payload.errors.length > 0) {
return payload.errors.map(String);
}
// ProblemDetails: errors is an object of field -> string[]
if (payload && payload.errors && typeof payload.errors === 'object') {
const flattened = [];
for (const value of Object.values(payload.errors)) {
if (Array.isArray(value)) flattened.push(...value.map(String));
}
if (flattened.length > 0) return flattened;
}
if (payload && typeof payload.title === 'string') return [payload.title];
if (response.status === 401) return ['Your session has expired. Please sign in again.'];
if (response.status === 404) return ['This project no longer exists.'];
return [`The server returned an unexpected error (${response.status}).`];
}
async function request(url, options = {}) {
let response;
try {
response = await fetch(url, Object.assign({ credentials: 'same-origin' }, options));
} catch {
throw new ApiError(0, ['Could not reach the server. Check your connection and try again.']);
}
if (!response.ok) throw new ApiError(response.status, await readErrors(response));
if (response.status === 204) return null;
try {
return await response.json();
} catch {
return null;
}
}
const jsonHeaders = { 'Content-Type': 'application/json' };
export function createApi(baseUrl) {
const base = (baseUrl || '/api/breadboard').replace(/\/+$/, '');
return {
listProjects() {
return request(`${base}/projects`);
},
createProject(name, description) {
return request(`${base}/projects`, {
method: 'POST',
headers: jsonHeaders,
body: JSON.stringify(description ? { name, description } : { name })
});
},
getProject(id) {
return request(`${base}/projects/${encodeURIComponent(id)}`);
},
/**
* Save the circuit only. Omitting name/description leaves them untouched
* server-side, which is why this sends nothing else.
*
* The payload is rebuilt by serializeCircuit so no editor state can leak into
* the document, and its byte size is checked first: the count caps do NOT
* imply the 2 MB byte cap, so without this the user would meet it as a bare
* rejection with no explanation.
*/
saveCircuit(id, circuit) {
const payload = serializeCircuit(circuit);
const bytes = circuitByteSize(payload);
if (bytes > MAX_CIRCUIT_BYTES) {
const over = Math.ceil((bytes - MAX_CIRCUIT_BYTES) / 1024);
return Promise.reject(new ApiError(0, [
`This circuit is ${(bytes / 1048576).toFixed(2)} MB, which is ${over} KB over the 2 MB limit.`,
`It has ${payload.components.length} components and ${payload.wires.length} wires — removing some will bring it under.`
]));
}
return request(`${base}/projects/${encodeURIComponent(id)}`, {
method: 'PUT',
headers: jsonHeaders,
body: JSON.stringify({ circuit: payload })
}).then(() => payload);
},
renameProject(id, name, description) {
const body = {};
if (name !== undefined) body.name = name;
if (description !== undefined) body.description = description;
return request(`${base}/projects/${encodeURIComponent(id)}`, {
method: 'PUT',
headers: jsonHeaders,
body: JSON.stringify(body)
});
},
deleteProject(id) {
return request(`${base}/projects/${encodeURIComponent(id)}`, { method: 'DELETE' });
}
};
}
@@ -0,0 +1,243 @@
// Static breadboard artwork, rasterized once and blitted.
//
// A board's face, holes, rails and silkscreen never change, so redrawing 830 holes per
// frame per board would be pure waste. This rasterizes ONE board bitmap and blits it
// for every board on the canvas - every board is visually identical, so the cache is a
// single image no matter how many boards the circuit has.
//
// CACHING POLICY (stated explicitly, since silence here is a bug waiting to happen):
// the bitmap is rasterized at a QUANTIZED scale, not at the exact zoom. Buckets are
// powers of two and we pick the smallest bucket at or above the current zoom, so a
// wheel gesture re-rasterizes at most a handful of times instead of on every tick. The
// cache is keyed by (scale bucket, palette version), so a theme change invalidates it.
//
// UP TO bucket 4 the blit only ever scales DOWN, which stays sharp. ABOVE it the art is
// magnified - viewport MAX_ZOOM is 6, so zoom 4-6 blits at up to 1.5x and the board
// looks mildly soft there. That is DELIBERATE, to bound memory: the bucket-4 bitmap is
// already 5120x1600 px, about 33 MB, and a bucket 8 would be ~131 MB. Slightly soft
// artwork at extreme zoom is the better trade. Note the 33 MB is allocated as soon as a
// user zooms past 2, and it is one bitmap total - every board blits from the same one.
import {
PITCH,
BOARD_COLUMNS,
BOARD_WIDTH,
BOARD_HEIGHT,
RAIL_HOLES,
MAIN_ROWS,
RAIL_NAMES,
CHANNEL_TOP,
CHANNEL_BOTTOM,
columnX,
rowY,
railHoleX,
railY
} from '../shared/board-geometry.js';
const SCALE_BUCKETS = Object.freeze([0.25, 0.5, 1, 2, 4]);
/**
* Smallest bucket at or above `zoom`. Below the top bucket the blit scales down; above
* it there is nothing sharper to pick, so the art is magnified. See the memory note at
* the top of this file for why the ladder stops at 4.
*/
export function scaleBucketFor(zoom) {
for (const bucket of SCALE_BUCKETS) {
if (zoom <= bucket) return bucket;
}
return SCALE_BUCKETS[SCALE_BUCKETS.length - 1];
}
function createSurface(width, height) {
if (typeof OffscreenCanvas === 'function') return new OffscreenCanvas(width, height);
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
return canvas;
}
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
/**
* Draw one board at 1:1 board-space scale. The caller has already applied the bucket
* scale to the context, so everything here is in board-space units.
*/
function paintBoard(ctx, colors) {
// Face
ctx.fillStyle = colors.boardFace;
roundRect(ctx, 0, 0, BOARD_WIDTH, BOARD_HEIGHT, PITCH * 0.4);
ctx.fill();
// A soft bevel along the top edge reads as moulded plastic without costing much.
const bevel = ctx.createLinearGradient(0, 0, 0, BOARD_HEIGHT);
bevel.addColorStop(0, colors.boardBevel);
bevel.addColorStop(0.06, colors.boardFace);
bevel.addColorStop(0.94, colors.boardFace);
bevel.addColorStop(1, colors.boardEdge);
ctx.fillStyle = bevel;
ctx.globalAlpha = 0.55;
roundRect(ctx, 0, 0, BOARD_WIDTH, BOARD_HEIGHT, PITCH * 0.4);
ctx.fill();
ctx.globalAlpha = 1;
ctx.strokeStyle = colors.boardEdge;
ctx.lineWidth = 1;
roundRect(ctx, 0.5, 0.5, BOARD_WIDTH - 1, BOARD_HEIGHT - 1, PITCH * 0.4);
ctx.stroke();
paintChannel(ctx, colors);
paintRailMarkings(ctx, colors);
paintHoles(ctx, colors);
paintSilkscreen(ctx, colors);
}
function paintChannel(ctx, colors) {
const height = CHANNEL_BOTTOM - CHANNEL_TOP;
const gradient = ctx.createLinearGradient(0, CHANNEL_TOP, 0, CHANNEL_BOTTOM);
gradient.addColorStop(0, colors.channelEdge);
gradient.addColorStop(0.35, colors.channel);
gradient.addColorStop(1, colors.channelEdge);
ctx.fillStyle = gradient;
ctx.fillRect(PITCH * 0.5, CHANNEL_TOP, BOARD_WIDTH - PITCH, height);
ctx.strokeStyle = colors.channelEdge;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(PITCH * 0.5, CHANNEL_TOP + 0.5);
ctx.lineTo(BOARD_WIDTH - PITCH * 0.5, CHANNEL_TOP + 0.5);
ctx.moveTo(PITCH * 0.5, CHANNEL_BOTTOM - 0.5);
ctx.lineTo(BOARD_WIDTH - PITCH * 0.5, CHANNEL_BOTTOM - 0.5);
ctx.stroke();
}
/** The red/blue guide lines that run alongside each power rail. */
function paintRailMarkings(ctx, colors) {
const x0 = railHoleX(1) - PITCH * 0.7;
const x1 = railHoleX(RAIL_HOLES) + PITCH * 0.7;
for (const rail of RAIL_NAMES) {
const isPlus = rail.endsWith('Plus');
const y = railY(rail);
// The stripe sits on the outer side of its rail, as on a real board.
const outward = (rail === 'topPlus' || rail === 'bottomMinus') ? -1 : 1;
const lineY = y + outward * PITCH * 0.62;
ctx.strokeStyle = isPlus ? colors.railPlus : colors.railMinus;
ctx.globalAlpha = 0.75;
ctx.lineWidth = Math.max(1, PITCH * 0.08);
ctx.beginPath();
ctx.moveTo(x0, lineY);
ctx.lineTo(x1, lineY);
ctx.stroke();
ctx.globalAlpha = 1;
// + and - symbols at both ends
ctx.fillStyle = isPlus ? colors.railPlus : colors.railMinus;
for (const x of [x0 - PITCH * 0.55, x1 + PITCH * 0.55]) {
const arm = PITCH * 0.22;
ctx.lineWidth = Math.max(1, PITCH * 0.09);
ctx.strokeStyle = ctx.fillStyle;
ctx.beginPath();
ctx.moveTo(x - arm, lineY);
ctx.lineTo(x + arm, lineY);
if (isPlus) {
ctx.moveTo(x, lineY - arm);
ctx.lineTo(x, lineY + arm);
}
ctx.stroke();
}
}
}
function paintHoles(ctx, colors) {
const radius = PITCH * 0.17;
const inset = PITCH * 0.28;
const drawHole = (x, y) => {
// Square socket recess, then the round hole - reads as a real breadboard.
ctx.fillStyle = colors.holeRim;
ctx.fillRect(x - inset, y - inset, inset * 2, inset * 2);
ctx.fillStyle = colors.hole;
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
};
for (let col = 1; col <= BOARD_COLUMNS; col++) {
const x = columnX(col);
for (const row of MAIN_ROWS) drawHole(x, rowY(row));
}
for (const rail of RAIL_NAMES) {
const y = railY(rail);
for (let i = 1; i <= RAIL_HOLES; i++) drawHole(railHoleX(i), y);
}
}
function paintSilkscreen(ctx, colors) {
ctx.fillStyle = colors.silk;
ctx.font = `${Math.round(PITCH * 0.42)}px system-ui, -apple-system, "Segoe UI", sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// Row letters, just outside the main grid at both ends.
for (const row of MAIN_ROWS) {
const y = rowY(row);
ctx.fillText(row, columnX(1) - PITCH * 0.75, y);
ctx.fillText(row, columnX(BOARD_COLUMNS) + PITCH * 0.75, y);
}
// Column numbers every 5 columns, above row a and below row j.
ctx.fillStyle = colors.silkStrong;
ctx.font = `${Math.round(PITCH * 0.38)}px system-ui, -apple-system, "Segoe UI", sans-serif`;
for (let col = 1; col <= BOARD_COLUMNS; col++) {
if (col !== 1 && col % 5 !== 0) continue;
const x = columnX(col);
ctx.fillText(String(col), x, rowY('a') - PITCH * 0.72);
ctx.fillText(String(col), x, rowY('j') + PITCH * 0.72);
}
}
/**
* Cache of rasterized board bitmaps, keyed by scale bucket and palette version.
* One entry serves every board in the circuit.
*/
export function createBoardArtCache() {
let cached = null; // { bucket, paletteVersion, surface }
return {
/**
* A board bitmap suitable for the given zoom, rasterizing only when the bucket
* or the theme has changed.
* @returns {{surface: (OffscreenCanvas|HTMLCanvasElement), bucket: number}}
*/
get(zoom, colors, paletteVersion) {
const bucket = scaleBucketFor(zoom);
if (cached !== null && cached.bucket === bucket && cached.paletteVersion === paletteVersion) {
return cached;
}
const surface = createSurface(
Math.ceil(BOARD_WIDTH * bucket),
Math.ceil(BOARD_HEIGHT * bucket)
);
const ctx = surface.getContext('2d');
ctx.save();
ctx.scale(bucket, bucket);
paintBoard(ctx, colors);
ctx.restore();
cached = { bucket, paletteVersion, surface };
return cached;
},
/** Drop the cached bitmap; the next get() re-rasterizes. */
invalidate() {
cached = null;
}
};
}
@@ -0,0 +1,536 @@
// Drawing routines for each component type.
//
// Everything here draws in WORLD space - the caller has already applied the
// device-pixel-ratio base transform and the pan/zoom world transform. One world unit
// is one board-space pixel, so PITCH is the hole spacing in the units used here.
//
// Colours come from the palette (CSS custom properties), never from literals, except
// LED lens colours which are a physical property of the part.
import {
PITCH,
holeWorldPos,
railY,
railHoleX,
RAIL_HOLES
} from '../shared/board-geometry.js';
import { getComponentDef, isChipType, ledColorHex } from '../shared/component-registry.js';
import { componentPinHoles } from '../shared/component-pins.js';
import { formatOhms } from './dom.js';
/**
* World positions of a component's pins.
* @returns {Array<{x:number,y:number}|null>} index-aligned with the pin list
*/
export function pinPositions(component, boards) {
return componentPinHoles(component).map(p => (p.hole === null ? null : holeWorldPos(p.hole, boards)));
}
/** Centre of a component's placed pins, or null when nothing is placed. */
export function componentCenter(component, boards) {
const points = pinPositions(component, boards).filter(p => p !== null);
if (points.length === 0) return null;
const sum = points.reduce((acc, p) => ({ x: acc.x + p.x, y: acc.y + p.y }), { x: 0, y: 0 });
return { x: sum.x / points.length, y: sum.y / points.length };
}
/**
* Axis-aligned world bounds of a component, padded to its drawn body.
* @returns {{x:number,y:number,w:number,h:number}|null}
*/
export function componentBounds(component, boards) {
const points = pinPositions(component, boards).filter(p => p !== null);
if (points.length === 0) return null;
const pad = PITCH * 0.5;
const xs = points.map(p => p.x);
const ys = points.map(p => p.y);
const x = Math.min(...xs) - pad;
const y = Math.min(...ys) - pad;
return { x, y, w: Math.max(...xs) + pad - x, h: Math.max(...ys) + pad - y };
}
function roundRect(ctx, x, y, w, h, r) {
const radius = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.arcTo(x + w, y, x + w, y + h, radius);
ctx.arcTo(x + w, y + h, x, y + h, radius);
ctx.arcTo(x, y + h, x, y, radius);
ctx.arcTo(x, y, x + w, y, radius);
ctx.closePath();
}
function drawLead(ctx, from, to, colors) {
ctx.strokeStyle = colors.resistorLead;
ctx.lineWidth = PITCH * 0.09;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(from.x, from.y);
ctx.lineTo(to.x, to.y);
ctx.stroke();
}
// --- Per-type painters. Each receives already-resolved pin positions. ---
function drawLed(ctx, component, pins, colors, state) {
const [anode, cathode] = pins;
if (!anode || !cathode) return;
const lens = ledColorHex(component.props && component.props.color);
const mid = { x: (anode.x + cathode.x) / 2, y: (anode.y + cathode.y) / 2 };
const radius = PITCH * 0.36;
const burned = state.burned === true;
const brightness = burned ? 0 : Math.max(0, Math.min(1, state.brightness || 0));
drawLead(ctx, anode, cathode, colors);
// Emission halo, drawn under the lens so the lens stays readable.
if (brightness > 0.01) {
const glowRadius = radius * (2.2 + brightness * 2.4);
const glow = ctx.createRadialGradient(mid.x, mid.y, radius * 0.4, mid.x, mid.y, glowRadius);
glow.addColorStop(0, lens);
glow.addColorStop(1, 'transparent');
ctx.globalAlpha = 0.15 + brightness * 0.55;
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(mid.x, mid.y, glowRadius, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
}
ctx.fillStyle = burned ? colors.burned : lens;
ctx.globalAlpha = burned ? 1 : 0.55 + brightness * 0.45;
ctx.beginPath();
ctx.arc(mid.x, mid.y, radius, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
// Flat on the cathode side, as on a real LED.
const angle = Math.atan2(cathode.y - anode.y, cathode.x - anode.x);
ctx.strokeStyle = burned ? colors.burned : colors.silkStrong;
ctx.lineWidth = PITCH * 0.07;
ctx.beginPath();
ctx.arc(mid.x, mid.y, radius, angle - Math.PI / 3, angle + Math.PI / 3);
ctx.stroke();
if (burned) {
ctx.strokeStyle = colors.invalid;
ctx.lineWidth = PITCH * 0.1;
ctx.beginPath();
ctx.moveTo(mid.x - radius * 0.7, mid.y - radius * 0.7);
ctx.lineTo(mid.x + radius * 0.7, mid.y + radius * 0.7);
ctx.moveTo(mid.x + radius * 0.7, mid.y - radius * 0.7);
ctx.lineTo(mid.x - radius * 0.7, mid.y + radius * 0.7);
ctx.stroke();
}
}
function drawResistor(ctx, component, pins, colors, state, zoom) {
const [a, b] = pins;
if (!a || !b) return;
drawLead(ctx, a, b, colors);
const angle = Math.atan2(b.y - a.y, b.x - a.x);
const length = Math.hypot(b.x - a.x, b.y - a.y);
const bodyLength = Math.max(PITCH * 0.8, Math.min(length * 0.62, PITCH * 2.4));
const bodyHeight = PITCH * 0.44;
ctx.save();
ctx.translate((a.x + b.x) / 2, (a.y + b.y) / 2);
ctx.rotate(angle);
ctx.fillStyle = colors.resistorBody;
roundRect(ctx, -bodyLength / 2, -bodyHeight / 2, bodyLength, bodyHeight, bodyHeight * 0.45);
ctx.fill();
// Value bands. Decorative rather than an encoded E12 colour code, but they read
// instantly as a resistor at any zoom.
ctx.fillStyle = colors.silkStrong;
const bandWidth = bodyLength * 0.075;
for (let i = 0; i < 3; i++) {
const x = -bodyLength * 0.3 + i * bodyLength * 0.2;
ctx.fillRect(x, -bodyHeight / 2, bandWidth, bodyHeight);
}
// The value itself, only when there are enough device pixels to read it.
if (zoom > 1.1) {
ctx.fillStyle = colors.silkStrong;
ctx.font = `${PITCH * 0.36}px system-ui, -apple-system, "Segoe UI", sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
const upsideDown = Math.abs(angle) > Math.PI / 2;
ctx.rotate(upsideDown ? Math.PI : 0);
ctx.fillText(formatOhms(component.props && component.props.ohms), 0, -bodyHeight * 0.75);
}
ctx.restore();
}
function drawDiode(ctx, component, pins, colors) {
const [anode, cathode] = pins;
if (!anode || !cathode) return;
drawLead(ctx, anode, cathode, colors);
const angle = Math.atan2(cathode.y - anode.y, cathode.x - anode.x);
const length = Math.hypot(cathode.x - anode.x, cathode.y - anode.y);
const bodyLength = Math.max(PITCH * 0.5, Math.min(length * 0.6, PITCH * 1.8));
const bodyHeight = PITCH * 0.34;
ctx.save();
ctx.translate((anode.x + cathode.x) / 2, (anode.y + cathode.y) / 2);
ctx.rotate(angle);
ctx.fillStyle = colors.diodeBody;
roundRect(ctx, -bodyLength / 2, -bodyHeight / 2, bodyLength, bodyHeight, bodyHeight * 0.3);
ctx.fill();
// The band marks the cathode, which after the rotation is always the +x end.
ctx.fillStyle = colors.diodeBand;
ctx.fillRect(bodyLength * 0.24, -bodyHeight / 2, bodyLength * 0.16, bodyHeight);
ctx.restore();
}
/**
* TO-92 package seen from above: a flat face with a domed back, sitting over its own
* three holes. Everything is drawn within half a pitch of the pin row so the body stays
* inside componentBounds, which is what hit-testing and dirty-rect culling use.
*/
function drawTransistor(ctx, component, pins, colors, zoom) {
const placed = pins.filter(p => p !== null);
if (placed.length < 3) return;
const xs = placed.map(p => p.x);
const ys = placed.map(p => p.y);
const x = Math.min(...xs);
const y = Math.min(...ys);
const w = Math.max(...xs) - x;
const cy = y + (Math.max(...ys) - y) / 2;
const pad = PITCH * 0.34;
const left = x - pad;
const right = x + w + pad;
const flatY = cy + PITCH * 0.42;
const backY = cy - PITCH * 0.52;
ctx.strokeStyle = colors.chipPin;
ctx.lineWidth = PITCH * 0.12;
ctx.lineCap = 'butt';
for (const pin of placed) {
ctx.beginPath();
ctx.moveTo(pin.x, flatY - PITCH * 0.1);
ctx.lineTo(pin.x, flatY + PITCH * 0.16);
ctx.stroke();
}
ctx.fillStyle = colors.transistorBody;
ctx.beginPath();
ctx.moveTo(left, flatY);
ctx.lineTo(right, flatY);
ctx.lineTo(right, backY + PITCH * 0.22);
ctx.quadraticCurveTo((left + right) / 2, backY - PITCH * 0.28, left, backY + PITCH * 0.22);
ctx.closePath();
ctx.fill();
const def = getComponentDef(component.type);
ctx.fillStyle = colors.chipLabel;
ctx.textAlign = 'center';
if (zoom > 1.4) {
// Leg letters, taken from the registry's pin names so they can never disagree
// with the netlist. They follow the pins, so a rotated part reads correctly.
ctx.font = `${PITCH * 0.26}px system-ui, -apple-system, "Segoe UI", sans-serif`;
ctx.textBaseline = 'bottom';
ctx.globalAlpha = 0.7;
for (let i = 0; i < 3; i++) {
if (pins[i]) ctx.fillText(def.pins[i].name[0].toUpperCase(), pins[i].x, flatY - PITCH * 0.08);
}
ctx.globalAlpha = 1;
}
if (zoom > 0.9) {
ctx.font = `${PITCH * 0.3}px system-ui, -apple-system, "Segoe UI", sans-serif`;
ctx.textBaseline = 'middle';
ctx.fillText(def.label, (left + right) / 2, backY + PITCH * 0.3);
}
}
function drawPushButton(ctx, component, pins, colors, state) {
const placed = pins.filter(p => p !== null);
if (placed.length < 4) return;
const xs = placed.map(p => p.x);
const ys = placed.map(p => p.y);
const x = Math.min(...xs);
const y = Math.min(...ys);
const w = Math.max(...xs) - x;
const h = Math.max(...ys) - y;
const pad = PITCH * 0.34;
for (const pin of placed) {
drawLead(ctx, pin, { x: x + w / 2, y: pin.y }, colors);
}
ctx.fillStyle = colors.buttonBody;
roundRect(ctx, x - pad, y - pad, w + pad * 2, h + pad * 2, PITCH * 0.18);
ctx.fill();
const pressed = state.pressed === true;
const cx = x + w / 2;
const cy = y + h / 2;
const capRadius = Math.min(w, h) * 0.42 + PITCH * 0.1;
ctx.fillStyle = pressed ? colors.buttonCapDown : colors.buttonCap;
ctx.beginPath();
ctx.arc(cx, cy, pressed ? capRadius * 0.88 : capRadius, 0, Math.PI * 2);
ctx.fill();
if (!pressed) {
ctx.strokeStyle = colors.buttonCapDown;
ctx.lineWidth = PITCH * 0.06;
ctx.beginPath();
ctx.arc(cx, cy, capRadius, 0, Math.PI * 2);
ctx.stroke();
}
}
function drawDipSwitch(ctx, component, pins, colors, state, zoom) {
const placed = pins.filter(p => p !== null);
if (placed.length < 16) return;
const xs = placed.map(p => p.x);
const ys = placed.map(p => p.y);
const x = Math.min(...xs);
const y = Math.min(...ys);
const w = Math.max(...xs) - x;
const h = Math.max(...ys) - y;
const pad = PITCH * 0.3;
ctx.fillStyle = colors.dipBody;
roundRect(ctx, x - pad, y - pad, w + pad * 2, h + pad * 2, PITCH * 0.12);
ctx.fill();
const on = (component.props && Array.isArray(component.props.on)) ? component.props.on : [];
const slotWidth = w / 7; // 8 switches across 7 column gaps
const slotHeight = h * 0.52;
for (let k = 0; k < 8; k++) {
// Switch k lives in the column of pins k+1 and 16-k.
const pin = pins[k];
if (!pin) continue;
const cx = pin.x;
const cy = y + h / 2;
const sw = slotWidth * 0.44;
ctx.fillStyle = colors.dipSwitchOff;
ctx.globalAlpha = 0.35;
roundRect(ctx, cx - sw / 2, cy - slotHeight / 2, sw, slotHeight, PITCH * 0.05);
ctx.fill();
ctx.globalAlpha = 1;
const isOn = on[k] === true;
ctx.fillStyle = isOn ? colors.dipSwitchOn : colors.dipSwitchOff;
const leverHeight = slotHeight * 0.42;
const leverY = isOn ? cy - slotHeight / 2 : cy + slotHeight / 2 - leverHeight;
roundRect(ctx, cx - sw / 2, leverY, sw, leverHeight, PITCH * 0.04);
ctx.fill();
}
if (zoom > 1.3) {
ctx.fillStyle = colors.dipSwitchOn;
ctx.globalAlpha = 0.75;
ctx.font = `${PITCH * 0.26}px system-ui, -apple-system, "Segoe UI", sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
for (let k = 0; k < 8; k++) {
if (pins[k]) ctx.fillText(String(k + 1), pins[k].x, y - pad + PITCH * 0.04);
}
ctx.globalAlpha = 1;
}
}
function drawChip(ctx, component, pins, colors, state, zoom) {
const placed = pins.filter(p => p !== null);
if (placed.length < 14) return;
const xs = placed.map(p => p.x);
const ys = placed.map(p => p.y);
const x = Math.min(...xs);
const y = Math.min(...ys);
const w = Math.max(...xs) - x;
const h = Math.max(...ys) - y;
const padX = PITCH * 0.32;
const padY = PITCH * 0.55;
// Legs
ctx.strokeStyle = colors.chipPin;
ctx.lineWidth = PITCH * 0.13;
ctx.lineCap = 'butt';
for (const pin of placed) {
const towardBody = pin.y < y + h / 2 ? 1 : -1;
ctx.beginPath();
ctx.moveTo(pin.x, pin.y);
ctx.lineTo(pin.x, pin.y + towardBody * padY * 0.8);
ctx.stroke();
}
ctx.fillStyle = colors.chipBody;
roundRect(ctx, x - padX, y - padY * 0.15, w + padX * 2, h + padY * 0.3, PITCH * 0.1);
ctx.fill();
// Pin-1 notch, at the end where pin 1 actually is.
const pin1 = pins[0];
const notchAtLeft = pin1 && pin1.x <= x + w / 2;
const notchX = notchAtLeft ? x - padX : x + w + padX;
ctx.fillStyle = colors.chipLabel;
ctx.globalAlpha = 0.35;
ctx.beginPath();
ctx.arc(notchX, y + h / 2, PITCH * 0.22, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
// Pin-1 dot
if (pin1) {
ctx.fillStyle = colors.chipLabel;
ctx.globalAlpha = 0.6;
ctx.beginPath();
ctx.arc(pin1.x, y + h / 2 - (pin1.y < y + h / 2 ? -1 : 1) * h * 0.28, PITCH * 0.1, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
}
if (zoom > 0.75) {
ctx.save();
ctx.translate(x + w / 2, y + h / 2);
// Row-'f' anchored chips are the package rotated 180 degrees; keep the text
// upright regardless so the part number stays readable.
ctx.fillStyle = colors.chipLabel;
ctx.font = `${PITCH * 0.42}px "SF Mono", ui-monospace, Consolas, monospace`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(component.type, 0, 0);
ctx.restore();
}
}
function drawPowerSupply(ctx, component, pins, colors, state, zoom, boards) {
const props = component.props || {};
const board = boards instanceof Map ? boards.get(props.board) : null;
if (!board) return;
const side = props.side === 'bottom' ? 'bottom' : 'top';
const plusY = railY(side === 'top' ? 'topPlus' : 'bottomPlus');
const minusY = railY(side === 'top' ? 'topMinus' : 'bottomMinus');
const midY = board.y + (plusY + minusY) / 2;
// Clipped to the right-hand end of the rails so it never covers holes.
const x = board.x + railHoleX(RAIL_HOLES) + PITCH * 1.4;
const w = PITCH * 2.6;
const h = PITCH * 1.7;
ctx.fillStyle = colors.supplyBody;
roundRect(ctx, x, midY - h / 2, w, h, PITCH * 0.2);
ctx.fill();
ctx.strokeStyle = colors.railPlus;
ctx.lineWidth = PITCH * 0.07;
ctx.stroke();
// Leads back to each rail
for (const [y, color] of [[board.y + plusY, colors.railPlus], [board.y + minusY, colors.railMinus]]) {
ctx.strokeStyle = color;
ctx.lineWidth = PITCH * 0.11;
ctx.beginPath();
ctx.moveTo(x, midY);
ctx.lineTo(board.x + railHoleX(RAIL_HOLES), y);
ctx.stroke();
}
if (zoom > 0.6) {
ctx.fillStyle = colors.supplyText;
ctx.font = `${PITCH * 0.5}px system-ui, -apple-system, "Segoe UI", sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('5V', x + w / 2, midY);
}
}
/**
* Draw one component.
* @param {CanvasRenderingContext2D} ctx world-transformed context
* @param {object} component
* @param {Map<string,object>} boards
* @param {object} colors palette
* @param {object} state runtime state: { brightness, burned, pressed }
* @param {number} zoom current zoom, used to drop text detail when it would be unreadable
*/
export function drawComponent(ctx, component, boards, colors, state, zoom) {
const def = getComponentDef(component.type);
if (def === null) return;
const pins = pinPositions(component, boards);
if (isChipType(component.type)) {
drawChip(ctx, component, pins, colors, state, zoom);
return;
}
switch (component.type) {
case 'led': drawLed(ctx, component, pins, colors, state); break;
case 'resistor': drawResistor(ctx, component, pins, colors, state, zoom); break;
case 'diode': drawDiode(ctx, component, pins, colors); break;
case 'npn': case 'pnp': case 'nmos': case 'pmos':
drawTransistor(ctx, component, pins, colors, zoom);
break;
case 'pushButton': drawPushButton(ctx, component, pins, colors, state); break;
case 'dipSwitch8': drawDipSwitch(ctx, component, pins, colors, state, zoom); break;
case 'powerSupply5V': drawPowerSupply(ctx, component, pins, colors, state, zoom, boards); break;
default: break;
}
}
/**
* Draw a wire as a shallow arc, so overlapping wires stay distinguishable.
* @param {object} [options] levelColor paints a halo showing the net's logic level
*/
export function drawWire(ctx, from, to, color, options = {}) {
const dx = to.x - from.x;
const dy = to.y - from.y;
const length = Math.hypot(dx, dy);
// Perpendicular sag proportional to length, capped so long wires do not balloon.
const sag = Math.min(length * 0.14, PITCH * 2.2);
const mid = { x: (from.x + to.x) / 2 - (dy / (length || 1)) * sag, y: (from.y + to.y) / 2 + (dx / (length || 1)) * sag };
const stroke = () => {
ctx.beginPath();
ctx.moveTo(from.x, from.y);
ctx.quadraticCurveTo(mid.x, mid.y, to.x, to.y);
ctx.stroke();
};
ctx.lineCap = 'round';
if (options.levelColor) {
ctx.strokeStyle = options.levelColor;
ctx.globalAlpha = 0.5;
ctx.lineWidth = PITCH * 0.42;
stroke();
ctx.globalAlpha = 1;
}
ctx.strokeStyle = options.shadow || 'rgba(0,0,0,0.25)';
ctx.lineWidth = PITCH * 0.24;
ctx.save();
ctx.translate(0, PITCH * 0.06);
stroke();
ctx.restore();
ctx.strokeStyle = color;
ctx.lineWidth = PITCH * 0.2;
stroke();
// End collars, so a wire visibly plugs into its hole.
ctx.fillStyle = color;
for (const point of [from, to]) {
ctx.beginPath();
ctx.arc(point.x, point.y, PITCH * 0.15, 0, Math.PI * 2);
ctx.fill();
}
}
@@ -0,0 +1,116 @@
// Small DOM helpers for the breadboard editor.
//
// Everything user-provided reaches the page through textContent, never innerHTML -
// project names and server error strings are both untrusted as far as this file is
// concerned.
/**
* Create an element.
* @param {string} tag
* @param {object} [options] className, id, title, type, text, attrs, dataset, children
* @returns {HTMLElement}
*/
export function el(tag, options = {}) {
const node = document.createElement(tag);
if (options.className) node.className = options.className;
if (options.id) node.id = options.id;
if (options.title) node.title = options.title;
if (options.type) node.type = options.type;
if (options.text !== undefined) node.textContent = String(options.text);
if (options.attrs) {
for (const [key, value] of Object.entries(options.attrs)) {
if (value !== null && value !== undefined) node.setAttribute(key, String(value));
}
}
if (options.dataset) {
for (const [key, value] of Object.entries(options.dataset)) node.dataset[key] = String(value);
}
for (const child of options.children || []) {
if (child) node.appendChild(child);
}
return node;
}
/** Create a button that never submits a form. */
export function button(text, options = {}) {
return el('button', Object.assign({ type: 'button', text }, options));
}
/** Replace an element's contents with plain text. Safe for untrusted strings. */
export function setText(node, text) {
node.textContent = text === null || text === undefined ? '' : String(text);
}
/** Remove every child of an element. */
export function clear(node) {
while (node.firstChild) node.removeChild(node.firstChild);
}
/**
* Collects event listeners so they can all be removed in one call. Every listener in
* the editor goes through this - untracked listeners leak when the page is torn down.
*/
export function createListenerBag() {
const entries = [];
return {
/** @returns {Function} a function that removes just this listener */
on(target, type, handler, options) {
target.addEventListener(type, handler, options);
const entry = { target, type, handler, options };
entries.push(entry);
return () => {
target.removeEventListener(type, handler, options);
const i = entries.indexOf(entry);
if (i !== -1) entries.splice(i, 1);
};
},
removeAll() {
for (const e of entries) e.target.removeEventListener(e.type, e.handler, e.options);
entries.length = 0;
}
};
}
/**
* Trailing-edge debounce. `cancel()` drops a pending call, `flush()` runs it now.
*/
export function debounce(fn, delay) {
let timer = null;
let pendingArgs = null;
const wrapped = (...args) => {
pendingArgs = args;
if (timer !== null) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
const a = pendingArgs;
pendingArgs = null;
fn(...a);
}, delay);
};
wrapped.cancel = () => {
if (timer !== null) clearTimeout(timer);
timer = null;
pendingArgs = null;
};
wrapped.flush = () => {
if (timer === null) return;
clearTimeout(timer);
timer = null;
const a = pendingArgs;
pendingArgs = null;
fn(...a);
};
return wrapped;
}
/** Format a resistance for display: 220 -> "220 Ω", 4700 -> "4.7 kΩ". */
export function formatOhms(ohms) {
if (!Number.isFinite(ohms)) return '—';
if (ohms >= 1000000) return `${trimZeros(ohms / 1000000)}`;
if (ohms >= 1000) return `${trimZeros(ohms / 1000)}`;
return `${trimZeros(ohms)} Ω`;
}
function trimZeros(value) {
return String(Number(value.toFixed(2)));
}
@@ -0,0 +1,380 @@
// Editor document state: the circuit, the selection, and what has changed.
//
// DIRTY TRACKING (amendment A9). The circuit is stored in PostgreSQL as jsonb, which
// normalizes object key order and whitespace, so the bytes we send are never the bytes
// that come back. Comparing serialized strings across a save therefore reports a
// document as dirty the moment it is reloaded - and with an autosave that is a loop
// that never settles. So:
// - the baseline is a deep clone of exactly what we last SENT (never a re-GET),
// - comparison is structural, over a canonical form with sorted keys,
// - and no re-GET happens after a save, since PUT returns 204 with no body.
//
// UID ALLOCATION. One long-lived allocator lives here, seeded from the loaded
// document. Allocation is editor session state, not a property of the document, so
// nothing in this module calls shared/'s nextUid.
import {
createUidAllocator,
createWire,
addBoard as schemaAddBoard,
removeBoard as schemaRemoveBoard,
boardsByUid,
allUids,
validateCircuit,
MAX_COMPONENTS,
MAX_WIRES,
MAX_BOARDS,
DEFAULT_WIRE_COLOR,
orientFor
} from '../shared/circuit-schema.js';
import { getComponentDef, dipRowForOrient, defaultPropsFor } from '../shared/component-registry.js';
import { isFullyPlaced, componentSelfShorts } from '../shared/component-pins.js';
import { holeKey, sameHole } from '../shared/board-geometry.js';
/** Uid carried by the placement preview; never added to the circuit. */
const GHOST_UID = '__ghost__';
/** Stable stringification: object keys sorted, so key order never affects equality. */
function canonical(value) {
if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
const keys = Object.keys(value).sort();
return `{${keys.map(k => `${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}`;
}
export function createEditorState(initialCircuit) {
let circuit = initialCircuit;
let baseline = canonical(initialCircuit);
let allocator = createUidAllocator(allUids(initialCircuit));
let boards = boardsByUid(circuit);
const selection = new Set();
const listeners = new Set();
/** Components the user is physically holding down right now. Never persisted. */
const pressed = new Set();
function notify(change) {
for (const listener of listeners) listener(change);
}
function structureChanged() {
boards = boardsByUid(circuit);
notify({ kind: 'circuit' });
}
return {
get circuit() { return circuit; },
get boards() { return boards; },
get selection() { return selection; },
get pressed() { return pressed; },
get dirty() { return canonical(circuit) !== baseline; },
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
/**
* Mark the document saved. Pass the EXACT payload that was sent - the baseline
* must be what we sent, never a re-read of the server's copy.
*/
markSaved(sentPayload) {
baseline = canonical(sentPayload);
notify({ kind: 'saved' });
},
/** Replace the whole document, e.g. after a load. */
replace(nextCircuit) {
circuit = nextCircuit;
baseline = canonical(nextCircuit);
allocator = createUidAllocator(allUids(nextCircuit));
selection.clear();
pressed.clear();
structureChanged();
},
// --- Selection ---
select(uid, additive = false) {
if (!additive) selection.clear();
if (uid !== null && uid !== undefined) selection.add(uid);
notify({ kind: 'selection' });
},
toggleSelect(uid) {
if (selection.has(uid)) selection.delete(uid);
else selection.add(uid);
notify({ kind: 'selection' });
},
clearSelection() {
if (selection.size === 0) return;
selection.clear();
notify({ kind: 'selection' });
},
findSelectedComponent() {
if (selection.size !== 1) return null;
const uid = [...selection][0];
return circuit.components.find(c => c.uid === uid) || null;
},
// --- Components ---
/**
* Build a component of the given type at an anchor, without adding it. Used for
* the placement ghost so the preview is the real thing.
*/
buildComponent(type, anchor, extraProps) {
const def = getComponentDef(type);
if (def === null) return null;
// Deliberately does NOT allocate a uid. The placement ghost is rebuilt on
// every pointer move, and uid allocation is O(document) - doing it here
// would scan the whole circuit once per mousemove.
const component = {
uid: GHOST_UID,
type,
props: defaultPropsFor(type)
};
if (extraProps) Object.assign(component.props, extraProps);
if (!def.anchorless) component.anchor = anchor;
if (def.orientable) component.orient = orientFor(def, anchor, def.defaultOrient);
return component;
},
/**
* Add a component. Returns { ok, component, reason, warnings }.
* `warnings` is advisory - a self-shorted part is legal but useless.
*/
addComponent(type, anchor, extraProps) {
if (circuit.components.length >= MAX_COMPONENTS) {
return { ok: false, reason: `A circuit can hold at most ${MAX_COMPONENTS} components.` };
}
const def = getComponentDef(type);
if (def === null) return { ok: false, reason: `Unknown component type "${type}".` };
const component = {
uid: allocator.next('c'),
type,
props: defaultPropsFor(type) // deep-copies array defaults
};
if (extraProps) Object.assign(component.props, extraProps);
if (!def.anchorless) component.anchor = anchor;
if (def.orientable) component.orient = orientFor(def, anchor, def.defaultOrient);
if (!isFullyPlaced(component)) {
return { ok: false, reason: `A ${def.label} does not fit there — part of it would hang off the board.` };
}
circuit.components.push(component);
const check = validateCircuit(circuit);
if (!check.ok) {
circuit.components.pop();
return { ok: false, reason: check.errors[0] };
}
const warnings = [];
if (componentSelfShorts(component).length > 0) {
warnings.push(`This ${def.label} has both ends in the same connected strip, so it will have no effect.`);
}
structureChanged();
return { ok: true, component, warnings };
},
/** Move a component to a new anchor. Returns { ok, reason }. */
moveComponent(uid, anchor) {
const component = circuit.components.find(c => c.uid === uid);
if (!component) return { ok: false, reason: 'That component no longer exists.' };
const def = getComponentDef(component.type);
if (def.anchorless) return { ok: false, reason: `A ${def.label} is moved by changing its rail, not by dragging.` };
const previousAnchor = component.anchor;
const previousOrient = component.orient;
component.anchor = anchor;
if (def.orientable) component.orient = orientFor(def, anchor, component.orient);
if (!isFullyPlaced(component) || !validateCircuit(circuit).ok) {
component.anchor = previousAnchor;
component.orient = previousOrient;
return { ok: false, reason: `A ${def.label} does not fit there.` };
}
structureChanged();
return { ok: true };
},
/**
* Rotate a component. For DIP-style packages this flips the anchor across the
* center channel, which IS the rotation; for an LED it cycles the orient.
*/
rotateComponent(uid) {
const component = circuit.components.find(c => c.uid === uid);
if (!component) return { ok: false, reason: 'That component no longer exists.' };
const def = getComponentDef(component.type);
if (!def.orientable) return { ok: false, reason: `A ${def.label} cannot be rotated.` };
const previousAnchor = component.anchor;
const previousOrient = component.orient;
if (def.dipStyle) {
const nextOrient = component.orient === 'right' ? 'left' : 'right';
component.anchor = Object.assign({}, component.anchor, { row: dipRowForOrient(nextOrient) });
component.orient = nextOrient;
} else {
const values = def.orientValues;
const index = values.indexOf(component.orient);
component.orient = values[(index + 1) % values.length];
}
if (!isFullyPlaced(component) || !validateCircuit(circuit).ok) {
component.anchor = previousAnchor;
component.orient = previousOrient;
return { ok: false, reason: `A ${def.label} does not fit in that orientation here.` };
}
structureChanged();
return { ok: true };
},
/** Update a component's props. Reverts and explains if the result is invalid. */
setComponentProps(uid, changes) {
const component = circuit.components.find(c => c.uid === uid);
if (!component) return { ok: false, reason: 'That component no longer exists.' };
const previous = Object.assign({}, component.props);
Object.assign(component.props, changes);
const check = validateCircuit(circuit);
if (!check.ok || !isFullyPlaced(component)) {
component.props = previous;
return { ok: false, reason: check.ok ? 'That change would move a pin off the board.' : check.errors[0] };
}
structureChanged();
return { ok: true };
},
/** Toggle one switch of a DIP package. */
toggleSwitch(uid, switchNumber) {
const component = circuit.components.find(c => c.uid === uid);
if (!component || component.type !== 'dipSwitch8') return null;
const on = Array.isArray(component.props.on) ? component.props.on.slice() : new Array(8).fill(false);
const index = switchNumber - 1;
if (index < 0 || index >= on.length) return null;
on[index] = !on[index];
component.props.on = on;
structureChanged();
return on[index];
},
setPressed(uid, isPressed) {
if (isPressed) pressed.add(uid);
else pressed.delete(uid);
notify({ kind: 'runtime' });
},
// --- Wires ---
/** Add a wire. Returns { ok, wire, reason }. */
addWire(from, to, color) {
if (circuit.wires.length >= MAX_WIRES) {
return { ok: false, reason: `A circuit can hold at most ${MAX_WIRES} wires.` };
}
if (sameHole(from, to)) {
return { ok: false, reason: 'A wire needs two different holes.' };
}
const duplicate = circuit.wires.some(w =>
(sameHole(w.from, from) && sameHole(w.to, to)) || (sameHole(w.from, to) && sameHole(w.to, from)));
if (duplicate) return { ok: false, reason: 'Those holes are already joined by a wire.' };
const wire = {
uid: allocator.next('w'),
from,
to,
color: color || DEFAULT_WIRE_COLOR
};
circuit.wires.push(wire);
const check = validateCircuit(circuit);
if (!check.ok) {
circuit.wires.pop();
return { ok: false, reason: check.errors[0] };
}
structureChanged();
return { ok: true, wire };
},
setWireColor(uid, color) {
const wire = circuit.wires.find(w => w.uid === uid);
if (!wire) return false;
wire.color = color;
structureChanged();
return true;
},
// --- Deletion ---
/** Delete everything selected. Returns the number of items removed. */
deleteSelected() {
if (selection.size === 0) return 0;
const before = circuit.components.length + circuit.wires.length;
circuit.components = circuit.components.filter(c => !selection.has(c.uid));
circuit.wires = circuit.wires.filter(w => !selection.has(w.uid));
const removed = before - (circuit.components.length + circuit.wires.length);
if (removed > 0) {
selection.clear();
structureChanged();
}
return removed;
},
// --- Boards ---
addBoard() {
if (circuit.boards.length >= MAX_BOARDS) {
return { ok: false, reason: `A circuit can hold at most ${MAX_BOARDS} boards.` };
}
const board = schemaAddBoard(circuit);
if (board === null) return { ok: false, reason: 'Could not add another board.' };
allocator.claim(board.uid, 'b');
structureChanged();
return { ok: true, board };
},
/** Remove a board and everything on it. Returns { ok, reason, removed }. */
removeBoard(uid) {
if (circuit.boards.length <= 1) {
return { ok: false, reason: 'A circuit needs at least one board.' };
}
const before = circuit.components.length + circuit.wires.length;
if (!schemaRemoveBoard(circuit, uid)) {
return { ok: false, reason: 'That board no longer exists.' };
}
const removed = before - (circuit.components.length + circuit.wires.length);
selection.clear();
structureChanged();
return { ok: true, removed };
},
moveBoard(uid, x, y) {
const board = circuit.boards.find(b => b.uid === uid);
if (!board) return false;
board.x = x;
board.y = y;
structureChanged();
return true;
},
/** Component or wire whose uid matches, or null. */
findByUid(uid) {
return circuit.components.find(c => c.uid === uid)
|| circuit.wires.find(w => w.uid === uid)
|| null;
},
/** Wire whose either end is at the given hole, or null. */
wireAtHole(hole) {
const key = holeKey(hole);
return circuit.wires.find(w => holeKey(w.from) === key || holeKey(w.to) === key) || null;
}
};
}
export { canonical };
@@ -0,0 +1,474 @@
// Breadboard editor entry point.
//
// Boots into <div id="breadboard-editor" data-project-id data-project-name
// data-api-base>, which the Razor page renders empty - all chrome is built here.
//
// Everything user-supplied (project name, server messages, engine detail strings)
// reaches the DOM through textContent via the dom.js helpers. Nothing uses innerHTML.
import { el, createListenerBag, debounce } from './dom.js';
import { createPalette as createThemePalette } from './theme-colors.js';
import { createViewport } from './viewport.js';
import { createRenderer } from './renderer.js';
import { createEditorState } from './editor-state.js';
import { createTools } from './tools.js';
import { createToolbar } from './toolbar.js';
import { createPalette } from './palette.js';
import { createProperties } from './properties.js';
import { createStatus } from './status.js';
import { createApi, ApiError } from './api.js';
import { createSimClient } from './sim-client.js';
import { describeServerErrors } from './server-errors.js';
import {
normalizeCircuitWithReport,
createCircuit,
circuitBounds
} from '../shared/circuit-schema.js';
import { boardBounds } from '../shared/board-geometry.js';
/** How long after the last edit the simulation is re-loaded. */
const SIM_RELOAD_DEBOUNCE_MS = 350;
/** How long after the last edit an autosave fires. */
const AUTOSAVE_DEBOUNCE_MS = 4000;
const VIEW_STORAGE_PREFIX = 'bb-view-';
function readView(projectId) {
try {
const raw = localStorage.getItem(VIEW_STORAGE_PREFIX + projectId);
return raw ? JSON.parse(raw) : null;
} catch {
return null; // private mode, quota, corrupt entry - the view is optional
}
}
function writeView(projectId, view) {
try {
localStorage.setItem(VIEW_STORAGE_PREFIX + projectId, JSON.stringify(view));
} catch {
// Losing the remembered viewport is not worth surfacing.
}
}
export function boot(root) {
const projectId = root.dataset.projectId;
const apiBase = root.dataset.apiBase || '/api/breadboard';
// Shell
const toolbarHost = el('div', { className: 'bb-toolbar-host' });
const canvasContainer = el('div', { className: 'bb-canvas-container' });
const paletteHost = el('div', { className: 'bb-palette-host' });
const propsHost = el('div', { className: 'bb-props-host' });
const statusHost = el('div', { className: 'bb-status-host', id: 'bb-warnings' });
const workspace = el('div', {
className: 'bb-workspace',
children: [paletteHost, canvasContainer, propsHost]
});
const shell = el('div', { className: 'bb-editor-shell', children: [toolbarHost, workspace] });
root.appendChild(shell);
// The status strip floats over the bottom of the canvas rather than sitting below
// it in the column. In the column, every message that appeared or timed out resized
// the canvas container, which reallocated all four backing stores and forced a full
// repaint - twice per message.
canvasContainer.appendChild(statusHost);
const status = createStatus(statusHost);
if (!projectId) {
status.error('This page did not receive a project id, so there is nothing to edit.');
return { destroy() { status.destroy(); root.removeChild(shell); } };
}
const bag = createListenerBag();
const api = createApi(apiBase);
const themePalette = createThemePalette(root);
const viewport = createViewport();
const renderer = createRenderer(canvasContainer, viewport, themePalette, {
onPaintError(layer, message) {
// A blank canvas with no explanation is the worst possible failure mode.
status.error(`The ${layer} layer failed to draw: ${message}`);
}
});
const state = createEditorState(createCircuit());
let destroyed = false;
let saving = false;
/** Pending properties-panel rebuild. See scheduleProperties. */
let propertiesFrame = null;
let simLoadedOnce = false;
/** Components named by a simulation warning, highlighted on the canvas. */
const warnedUids = new Set();
/**
* Record the components a warning blames, so the overlay can point at them.
*
* Single rule for BOTH the load-time warnings array and the streaming warning
* message: warningsSuppressed is a coalescing summary rather than a fault on any
* component, so it must never paint a highlight. Enforcing it in one place stops
* the two paths from disagreeing.
*/
function addWarnedUids(warning) {
if (!warning || warning.kind === 'warningsSuppressed') return;
for (const uid of warning.uids || []) warnedUids.add(uid);
}
// --- Simulation ---
const sim = createSimClient({
loaded({ netCount, warnings, failed }) {
simLoadedOnce = true;
if (!failed) warnedUids.clear();
for (const warning of warnings) addWarnedUids(warning);
// A failed load still posts `loaded` so nothing waits forever. Keep the
// error that came with it instead of clearing the list and announcing
// success, and do not report the failure a second time.
if (!failed) status.clearWarnings();
for (const warning of warnings) status.addWarning(warning);
refreshSimState();
pushSimScene('dynamic', 'overlay');
if (!failed && netCount > 0) {
status.info(`Simulation ready — ${netCount} net${netCount === 1 ? '' : 's'}.`);
}
},
frame() {
pushSimScene();
refreshSimState();
},
warning(warning) {
status.addWarning(warning);
addWarnedUids(warning);
// Repaint here rather than waiting for an unrelated message: shortCircuit
// and oscillation HALT the engine, so there may be no further frame at all -
// and those are exactly the warnings whose components most need pointing at.
pushSimScene('dynamic', 'overlay');
// A halt is worth an explicit message; the engine refuses to run into it.
if (warning.kind === 'shortCircuit') {
status.error('Simulation halted: the supply rails are shorted together.');
} else if (warning.kind === 'oscillation') {
status.warn('Simulation halted: the circuit oscillates without settling.');
}
refreshSimState();
},
unavailable(message) {
status.warn(`Simulation is unavailable: ${message}`);
refreshSimState();
}
});
/**
* Hand the current simulation state to the renderer.
*
* Defaults to the dynamic layer alone. The overlay only shows simulation state
* through `warnedUids`, which changes on a warning - not on every frame - so
* callers that touch warnedUids pass 'dynamic', 'overlay' explicitly.
*/
function pushSimScene(...layers) {
renderer.setScene({
netLevels: sim.state.netLevels,
netOfStrip: sim.state.netOfStrip,
ledBrightness: sim.state.ledBrightness,
burned: sim.state.burned,
warnedUids,
simActive: sim.state.loaded
}, ...(layers.length > 0 ? layers : ['dynamic']));
}
/**
* Rebuild the properties panel at most once per frame. render() throws away and
* rebuilds the whole panel, and a component drag fires a circuit change on every
* pointermove, so calling it directly meant a full DOM teardown per mouse move.
*/
function scheduleProperties() {
if (propertiesFrame !== null) return;
propertiesFrame = requestAnimationFrame(() => {
propertiesFrame = null;
if (!destroyed) properties.render(state);
});
}
function refreshSimState() {
toolbar.setSimState({
available: sim.available,
running: sim.state.running,
settled: sim.state.settled,
halted: sim.state.halted,
loaded: simLoadedOnce
});
}
const reloadSim = debounce(() => {
if (destroyed || !sim.available) return;
warnedUids.clear();
sim.load(state.circuit);
}, SIM_RELOAD_DEBOUNCE_MS);
// --- Persistence ---
function updateSaveState() {
if (saving) {
toolbar.setSaveState('saving', 'Saving…');
} else if (state.dirty) {
toolbar.setSaveState('dirty', 'Unsaved changes');
} else {
toolbar.setSaveState('clean', 'Saved');
}
}
async function save() {
if (destroyed || saving || !state.dirty) return;
saving = true;
updateSaveState();
try {
// The reporting form, so anything the document cannot carry is surfaced
// rather than silently dropped on the way out.
// Neutral lead-in: some problems are renames, which ARE saved, just under
// a different id. "Not saved" would be false for those.
const { problems } = normalizeCircuitWithReport(state.circuit);
for (const problem of problems) status.warn(`On save: ${problem.reason}`);
const sent = await api.saveCircuit(projectId, state.circuit);
state.markSaved(sent);
status.info('Saved.');
} catch (error) {
if (error instanceof ApiError) {
status.errors(describeServerErrors(error.messages));
} else {
status.error('Saving failed unexpectedly.');
}
} finally {
saving = false;
updateSaveState();
}
}
const autosave = debounce(() => {
if (state.dirty && !saving) save();
}, AUTOSAVE_DEBOUNCE_MS);
// --- Chrome ---
const toolbar = createToolbar(toolbarHost, {
projectName: root.dataset.projectName || 'Breadboard',
onSave: save,
onRun: () => { sim.run(); refreshSimState(); },
onPause: () => { sim.pause(); refreshSimState(); },
onStep: () => sim.step(1),
onReset: () => {
// Clear BEFORE issuing the command: sending it can itself fail (an
// unclonable payload throws in postMessage), and clearing afterwards would
// wipe the very error the reset produced.
warnedUids.clear();
status.clearWarnings();
sim.reset();
status.info('Simulation reset.');
pushSimScene('dynamic', 'overlay');
},
onSpeed: (eventsPerSecond) => sim.setSpeed(eventsPerSecond),
onAddBoard: () => {
const result = state.addBoard();
if (!result.ok) status.warn(result.reason);
else status.info(`Added board ${result.board.uid}.`);
},
onZoom: (factor) => {
viewport.zoomAtCenter(renderer.width, renderer.height, factor);
afterViewportChange();
},
onZoomFit: () => fitAll()
});
const palette = createPalette(paletteHost, {
onTool: (tool) => {
tools.setTool(tool);
palette.setActiveTool(tool);
},
onWireColor: (color) => tools.setWireColor(color)
});
const properties = createProperties(propsHost, {
onChangeProps: (uid, changes) => {
const result = state.setComponentProps(uid, changes);
if (!result.ok) status.warn(result.reason);
},
onToggleSwitch: (uid, switchNumber) => {
const on = state.toggleSwitch(uid, switchNumber);
if (on !== null) sim.setSwitch(uid, switchNumber, on);
},
onRotate: (uid) => {
const result = state.rotateComponent(uid);
if (!result.ok) status.warn(result.reason);
},
onDelete: (uid) => {
state.select(uid);
const removed = state.deleteSelected();
if (removed > 0) status.info('Deleted.');
},
onDeleteSelection: () => {
const removed = state.deleteSelected();
if (removed > 0) status.info(`Deleted ${removed} items.`);
},
onFocusBoard: (uid) => {
const board = state.circuit.boards.find(b => b.uid === uid);
if (!board) return;
viewport.fit(boardBounds(board), renderer.width, renderer.height);
afterViewportChange();
},
onRemoveBoard: (uid) => {
const result = state.removeBoard(uid);
if (!result.ok) status.warn(result.reason);
else status.info(`Removed board ${uid}${result.removed > 0 ? ` and ${result.removed} item(s) on it` : ''}.`);
}
});
const tools = createTools({
canvas: renderer.canvas,
viewport,
state,
renderer,
sim,
status,
initialWireColor: palette.wireColor,
setTool: (tool) => {
tools.setTool(tool);
palette.setActiveTool(tool);
},
onSceneChange: () => {
toolbar.setZoom(viewport.zoom);
persistView();
},
onSelectionChange: () => scheduleProperties()
});
palette.setActiveTool({ kind: 'select', type: null });
// --- Wiring ---
const persistView = debounce(() => writeView(projectId, viewport.toJSON()), 400);
function afterViewportChange() {
renderer.viewportChanged();
toolbar.setZoom(viewport.zoom);
persistView();
}
function fitAll() {
viewport.fit(circuitBounds(state.circuit), renderer.width, renderer.height);
afterViewportChange();
}
function onContainerResize() {
tools.invalidateRect();
renderer.resize();
}
bag.on(window, 'resize', onContainerResize);
const resizeObserver = typeof ResizeObserver === 'function'
? new ResizeObserver(onContainerResize)
: null;
if (resizeObserver) resizeObserver.observe(canvasContainer);
bag.on(window, 'keydown', (event) => {
if ((event.ctrlKey || event.metaKey) && (event.key === 's' || event.key === 'S')) {
event.preventDefault();
save();
}
});
bag.on(window, 'beforeunload', (event) => {
if (!state.dirty) return;
event.preventDefault();
event.returnValue = '';
});
state.subscribe((change) => {
if (change.kind === 'circuit') {
tools.refresh('board', 'static', 'dynamic', 'overlay');
scheduleProperties();
reloadSim();
autosave();
} else if (change.kind === 'runtime') {
tools.refresh('dynamic', 'overlay');
}
updateSaveState();
});
// --- Load ---
async function load() {
try {
const project = await api.getProject(projectId);
if (destroyed) return;
toolbar.setProjectName(project.name);
if (project.name) document.title = `${project.name} — Breadboard`;
const { circuit, problems } = normalizeCircuitWithReport(project.circuit);
state.replace(circuit);
// Nothing the document could not carry is allowed to vanish quietly.
for (const problem of problems) status.warn(problem.reason);
if (problems.length > 0) {
status.warn(`${problems.length} item${problems.length === 1 ? '' : 's'} in the saved circuit could not be loaded exactly as stored.`);
}
renderer.resize();
const savedView = readView(projectId);
if (!viewport.restore(savedView)) fitAll();
else afterViewportChange();
tools.refresh('board', 'static', 'dynamic', 'overlay');
scheduleProperties();
updateSaveState();
if (sim.start()) sim.load(circuit);
sim.setSpeed(toolbar.initialSpeed);
refreshSimState();
} catch (error) {
if (error instanceof ApiError) status.errors(describeServerErrors(error.messages));
else status.error('The project could not be loaded.');
toolbar.setSaveState('error', 'Not loaded');
}
}
load();
return {
destroy() {
destroyed = true;
reloadSim.cancel();
autosave.cancel();
persistView.cancel();
if (propertiesFrame !== null) cancelAnimationFrame(propertiesFrame);
bag.removeAll();
if (resizeObserver) resizeObserver.disconnect();
tools.destroy();
properties.destroy();
palette.destroy();
toolbar.destroy();
renderer.destroy();
themePalette.destroy();
sim.destroy();
status.destroy();
if (shell.parentNode) shell.parentNode.removeChild(shell);
}
};
}
// Auto-boot. Module scripts are deferred, so the DOM is normally parsed by now, but
// guard anyway rather than assuming either way.
function start() {
const root = document.getElementById('breadboard-editor');
if (root && !root.dataset.booted) {
root.dataset.booted = 'true';
boot(root);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start, { once: true });
} else {
start();
}
@@ -0,0 +1,127 @@
// Component palette and tool selection.
//
// Driven entirely by the shared component registry, so a new component type appears
// here without touching this file.
import { el, button, createListenerBag } from './dom.js';
import { COMPONENT_TYPES, getComponentDef } from '../shared/component-registry.js';
import { WIRE_COLORS, DEFAULT_WIRE_COLOR } from '../shared/circuit-schema.js';
const CATEGORY_ORDER = Object.freeze(['passive', 'semiconductor', 'output', 'input', 'power', 'chip']);
const CATEGORY_LABELS = Object.freeze(Object.assign(Object.create(null), {
passive: 'Passive',
semiconductor: 'Semiconductor',
output: 'Output',
input: 'Input',
power: 'Power',
chip: 'Logic'
}));
export function createPalette(root, handlers) {
const bag = createListenerBag();
const toolButtons = new Map();
function toolButton(key, label, title) {
const node = button(label, { className: 'bb-tool', title });
node.dataset.tool = key;
toolButtons.set(key, node);
return node;
}
const selectButton = toolButton('select', 'Select', 'Select, move and rotate parts (Esc)');
const wireButton = toolButton('wire', 'Wire', 'Drag from hole to hole to run a wire');
const colorSwatches = el('div', { className: 'bb-swatches' });
let activeColor = DEFAULT_WIRE_COLOR;
const swatchNodes = new Map();
for (const color of WIRE_COLORS) {
const swatch = button('', {
className: 'bb-swatch',
title: `Wire colour ${color}`,
attrs: { 'aria-label': `Wire colour ${color}` }
});
swatch.style.background = color;
swatchNodes.set(color, swatch);
bag.on(swatch, 'click', () => {
setColor(color);
handlers.onWireColor(color);
});
colorSwatches.appendChild(swatch);
}
function setColor(color) {
activeColor = color;
for (const [value, node] of swatchNodes) node.classList.toggle('is-active', value === color);
}
setColor(DEFAULT_WIRE_COLOR);
const sections = [
el('div', {
className: 'bb-palette-section',
children: [
el('h2', { className: 'bb-palette-heading', text: 'Tools' }),
el('div', { className: 'bb-palette-grid', children: [selectButton, wireButton] }),
colorSwatches
]
})
];
// Component buttons, grouped by registry category.
const byCategory = new Map();
for (const type of COMPONENT_TYPES) {
const def = getComponentDef(type);
if (!byCategory.has(def.category)) byCategory.set(def.category, []);
byCategory.get(def.category).push(def);
}
for (const category of CATEGORY_ORDER) {
const defs = byCategory.get(category);
if (!defs || defs.length === 0) continue;
const grid = el('div', { className: 'bb-palette-grid' });
for (const def of defs) {
const node = toolButton(`place:${def.type}`, def.label, def.description || def.label);
node.classList.add('bb-tool-component');
grid.appendChild(node);
}
sections.push(el('div', {
className: 'bb-palette-section',
children: [
el('h2', { className: 'bb-palette-heading', text: CATEGORY_LABELS[category] || category }),
grid
]
}));
}
const hint = el('p', {
className: 'bb-palette-hint',
text: 'Space or middle-drag pans. Scroll to zoom. R rotates, Delete removes.'
});
sections.push(hint);
const panel = el('aside', { className: 'bb-palette', children: sections });
root.appendChild(panel);
for (const [key, node] of toolButtons) {
bag.on(node, 'click', () => {
if (key === 'select') handlers.onTool({ kind: 'select', type: null });
else if (key === 'wire') handlers.onTool({ kind: 'wire', type: null });
else handlers.onTool({ kind: 'place', type: key.slice('place:'.length) });
});
}
return {
get wireColor() { return activeColor; },
setActiveTool(tool) {
const key = tool.kind === 'place' ? `place:${tool.type}` : tool.kind;
for (const [name, node] of toolButtons) node.classList.toggle('is-active', name === key);
},
setWireColor: setColor,
destroy() {
bag.removeAll();
if (panel.parentNode) panel.parentNode.removeChild(panel);
}
};
}
@@ -0,0 +1,248 @@
// Properties side panel: edits the selected component, and manages boards.
//
// Controls are generated from the registry's propSpecs, so a new scalar property on a
// component type gets an editor here for free.
import { el, button, setText, clear, createListenerBag, formatOhms } from './dom.js';
import { getComponentDef, LED_COLORS } from '../shared/component-registry.js';
import { componentSelfShorts, componentPinsWithNames } from '../shared/component-pins.js';
function field(labelText, control) {
return el('label', {
className: 'bb-field',
children: [el('span', { className: 'bb-field-label', text: labelText }), control]
});
}
export function createProperties(root, handlers) {
// Panel-lifetime listeners.
const bag = createListenerBag();
// Listeners for the controls rebuilt on every render. Cleared each time, otherwise
// the bag would keep every detached button and its closure alive for the session.
const renderBag = createListenerBag();
const body = el('div', { className: 'bb-props-body' });
const boardsBody = el('div', { className: 'bb-boards-body' });
const panel = el('aside', {
className: 'bb-props',
children: [
el('h2', { className: 'bb-props-heading', text: 'Properties' }),
body,
el('h2', { className: 'bb-props-heading', text: 'Boards' }),
boardsBody
]
});
root.appendChild(panel);
function buildEnumControl(component, key, spec) {
const select = el('select', { className: 'bb-input' });
// LED colours get their swatch shown alongside the name.
const options = key === 'color'
? LED_COLORS.map(c => ({ value: c.value, label: c.label }))
: spec.values.map(v => ({ value: v, label: v }));
for (const option of options) {
const node = el('option', { text: option.label, attrs: { value: option.value } });
if (component.props[key] === option.value) node.selected = true;
select.appendChild(node);
}
renderBag.on(select, 'change', () => handlers.onChangeProps(component.uid, { [key]: select.value }));
return select;
}
function buildNumberControl(component, key, spec) {
const input = el('input', {
className: 'bb-input',
attrs: {
type: 'number', min: String(spec.min), max: String(spec.max),
step: '1', value: String(component.props[key])
}
});
const commit = () => {
const value = Number(input.value);
if (!Number.isFinite(value)) {
input.value = String(component.props[key]);
return;
}
const clamped = Math.min(spec.max, Math.max(spec.min, value));
input.value = String(clamped);
handlers.onChangeProps(component.uid, { [key]: clamped });
};
renderBag.on(input, 'change', commit);
renderBag.on(input, 'keydown', (event) => {
if (event.key === 'Enter') { event.preventDefault(); commit(); }
});
const wrapper = el('div', { className: 'bb-field-stack', children: [input] });
if (Array.isArray(spec.presets)) {
const presets = el('div', { className: 'bb-presets' });
for (const preset of spec.presets) {
const node = button(formatOhms(preset), { className: 'bb-chip-btn' });
renderBag.on(node, 'click', () => {
input.value = String(preset);
handlers.onChangeProps(component.uid, { [key]: preset });
});
presets.appendChild(node);
}
wrapper.appendChild(presets);
}
return wrapper;
}
function buildBoolArrayControl(component, key, spec) {
const row = el('div', { className: 'bb-switch-row' });
const values = Array.isArray(component.props[key]) ? component.props[key] : [];
for (let i = 0; i < spec.length; i++) {
const node = button(String(i + 1), {
className: `bb-switch-toggle${values[i] ? ' is-on' : ''}`,
title: `Switch ${i + 1}: ${values[i] ? 'on' : 'off'}`
});
renderBag.on(node, 'click', () => handlers.onToggleSwitch(component.uid, i + 1));
row.appendChild(node);
}
return row;
}
function renderComponent(component) {
const def = getComponentDef(component.type);
if (def === null) return;
body.appendChild(el('div', {
className: 'bb-props-title',
children: [
el('strong', { text: def.label }),
el('span', { className: 'bb-props-uid', text: component.uid })
]
}));
if (def.description) {
body.appendChild(el('p', { className: 'bb-props-description', text: def.description }));
}
for (const [key, spec] of Object.entries(def.propSpecs)) {
let control = null;
if (spec.kind === 'enum') control = buildEnumControl(component, key, spec);
else if (spec.kind === 'number') control = buildNumberControl(component, key, spec);
else if (spec.kind === 'boolArray') control = buildBoolArrayControl(component, key, spec);
if (control !== null) {
body.appendChild(field(spec.unit ? `${spec.label} (${spec.unit})` : spec.label, control));
}
}
// Placement summary - where the part actually sits.
const pins = componentPinsWithNames(component);
const placed = pins.filter(p => p.hole !== null);
if (placed.length > 0) {
const first = placed[0].hole;
const where = first.kind === 'main'
? `board ${first.board}, column ${first.col} row ${first.row}`
: `board ${first.board}, ${first.rail} rail`;
body.appendChild(el('p', { className: 'bb-props-meta', text: `${pins.length} pins at ${where}` }));
}
const shorts = componentSelfShorts(component);
if (shorts.length > 0) {
body.appendChild(el('p', {
className: 'bb-props-warning',
text: 'Both ends of this part sit in the same connected strip, so it will have no effect.'
}));
}
const actions = el('div', { className: 'bb-props-actions' });
if (def.orientable) {
const rotate = button('Rotate (R)', { className: 'bb-btn' });
renderBag.on(rotate, 'click', () => handlers.onRotate(component.uid));
actions.appendChild(rotate);
}
const remove = button('Delete', { className: 'bb-btn bb-btn-danger' });
renderBag.on(remove, 'click', () => handlers.onDelete(component.uid));
actions.appendChild(remove);
body.appendChild(actions);
}
function renderWire(wire) {
body.appendChild(el('div', {
className: 'bb-props-title',
children: [el('strong', { text: 'Wire' }), el('span', { className: 'bb-props-uid', text: wire.uid })]
}));
const describe = (hole) => hole.kind === 'main'
? `${hole.board} · ${hole.col}${hole.row}`
: `${hole.board} · ${hole.rail} ${hole.index}`;
body.appendChild(el('p', {
className: 'bb-props-meta',
text: `${describe(wire.from)}${describe(wire.to)}`
}));
const actions = el('div', { className: 'bb-props-actions' });
const remove = button('Delete', { className: 'bb-btn bb-btn-danger' });
renderBag.on(remove, 'click', () => handlers.onDelete(wire.uid));
actions.appendChild(remove);
body.appendChild(actions);
}
return {
/** Re-render for the current selection. */
render(state) {
renderBag.removeAll();
clear(body);
const selection = [...state.selection];
if (selection.length === 0) {
body.appendChild(el('p', {
className: 'bb-props-empty',
text: 'Select a part or wire to edit it.'
}));
} else if (selection.length > 1) {
body.appendChild(el('p', {
className: 'bb-props-empty',
text: `${selection.length} items selected.`
}));
const actions = el('div', { className: 'bb-props-actions' });
const remove = button(`Delete ${selection.length} items`, { className: 'bb-btn bb-btn-danger' });
renderBag.on(remove, 'click', () => handlers.onDeleteSelection());
actions.appendChild(remove);
body.appendChild(actions);
} else {
const uid = selection[0];
const component = state.circuit.components.find(c => c.uid === uid);
if (component) renderComponent(component);
else {
const wire = state.circuit.wires.find(w => w.uid === uid);
if (wire) renderWire(wire);
}
}
this.renderBoards(state);
},
/** Rebuilds the board list. Its listeners belong to renderBag - render() clears
* it first, so calling this directly is only valid from render(). */
renderBoards(state) {
clear(boardsBody);
for (const board of state.circuit.boards) {
const onBoard = state.circuit.components.filter(c =>
(c.anchor && c.anchor.board === board.uid) || (c.props && c.props.board === board.uid)).length;
const row = el('div', { className: 'bb-board-row' });
row.appendChild(el('span', { className: 'bb-board-name', text: board.uid }));
row.appendChild(el('span', { className: 'bb-board-count', text: `${onBoard} part${onBoard === 1 ? '' : 's'}` }));
const focus = button('Show', { className: 'bb-btn bb-btn-small' });
renderBag.on(focus, 'click', () => handlers.onFocusBoard(board.uid));
row.appendChild(focus);
if (state.circuit.boards.length > 1) {
const remove = button('Remove', { className: 'bb-btn bb-btn-small bb-btn-danger' });
renderBag.on(remove, 'click', () => handlers.onRemoveBoard(board.uid));
row.appendChild(remove);
}
boardsBody.appendChild(row);
}
},
destroy() {
renderBag.removeAll();
bag.removeAll();
if (panel.parentNode) panel.parentNode.removeChild(panel);
}
};
}
@@ -0,0 +1,428 @@
// Layered canvas renderer.
//
// ============================ COORDINATE / SCALE POLICY ============================
// devicePixelRatio lives OUTSIDE the world transform, and this is the only file that
// knows about it. Each canvas backing store is sized cssPx * dpr; before drawing we
// set the BASE transform to (dpr, 0, 0, dpr, 0, 0), then apply the pan/zoom world
// transform on top of it. Consequences, relied on everywhere else:
// - viewport.js, board-geometry.js and component-art.js never see device pixels.
// - a "line width of 2" is 2 CSS pixels at zoom 1, on every display.
// - hit-testing uses viewport.screenToWorld on CSS-pixel mouse coordinates, with no
// dpr correction anywhere.
//
// ================================ LAYERS & DIRTYING ================================
// Four stacked canvases, each redrawn only when something it depends on changes.
//
// board static board artwork, blitted from a cached bitmap (board-art.js).
// Dirty on: board add/move/remove, theme, viewport.
// static components whose appearance CANNOT change during simulation - chips,
// resistors, supplies. These carry the expensive text.
// Dirty on: circuit edit, theme, viewport.
// dynamic wires (with net-level halos) and the components that DO change - LEDs,
// buttons, DIP switches.
// Dirty on: circuit edit, theme, viewport, and every simulation frame.
// overlay hover highlight, selection, drag ghost, in-progress wire.
// Dirty on: pointer/selection changes only.
//
// Each component is drawn on exactly one layer, chosen by whether simulation can alter
// it, so a 60 fps frame never re-rasterizes chip labels and a mousemove that changes
// nothing draws nothing.
import {
PITCH,
BOARD_WIDTH,
BOARD_HEIGHT,
holeWorldPos,
boardBounds,
stripKey
} from '../shared/board-geometry.js';
import { createBoardArtCache } from './board-art.js';
import { drawComponent, drawWire, componentBounds } from './component-art.js';
import { rectsIntersect } from './viewport.js';
/** Components whose drawn appearance depends on simulation state. */
const VOLATILE_TYPES = new Set(['led', 'pushButton', 'dipSwitch8']);
const LAYER_NAMES = Object.freeze(['board', 'static', 'dynamic', 'overlay']);
export function createRenderer(container, viewport, palette, options = {}) {
const canvases = {};
const contexts = {};
const boardArt = createBoardArtCache();
for (const name of LAYER_NAMES) {
const canvas = document.createElement('canvas');
canvas.className = `bb-layer bb-layer-${name}`;
// Only the topmost layer takes pointer events; the rest are pure paint.
canvas.style.pointerEvents = name === 'overlay' ? 'auto' : 'none';
container.appendChild(canvas);
canvases[name] = canvas;
contexts[name] = canvas.getContext('2d');
}
canvases.overlay.tabIndex = 0; // focusable, so the canvas can own keyboard
// Latest MEASURED size, in CSS pixels. Read straight after resize() so callers
// that fit the view can trust it.
let cssWidth = 0;
let cssHeight = 0;
let dpr = 1;
// Size the backing stores currently hold. Reallocating them costs tens of MB of
// churn, so it happens once per frame at most, inside paint().
let appliedWidth = 0;
let appliedHeight = 0;
let appliedDpr = 0;
const dirty = { board: true, static: true, dynamic: true, overlay: true };
let frameHandle = null;
// Latest scene to draw. Replaced wholesale by the editor on each change.
let scene = {
circuit: null,
boards: new Map(),
selection: new Set(),
hoverHole: null,
hoverComponent: null,
pendingWire: null, // { from: holeRef, toPoint: {x,y}, color }
ghost: null, // { component, valid }
ledBrightness: new Map(),
burned: new Set(),
pressed: new Set(),
warnedUids: new Set(), // components a simulation warning named
netLevels: null, // Uint8Array
netOfStrip: null, // Map<stripKey, netId>
simActive: false
};
/** Read the container size. Cheap - one layout read, no canvas work. */
function measureContainer() {
const rect = container.getBoundingClientRect();
const nextDpr = window.devicePixelRatio || 1;
const width = Math.max(1, Math.round(rect.width));
const height = Math.max(1, Math.round(rect.height));
if (width === cssWidth && height === cssHeight && nextDpr === dpr) return false;
cssWidth = width;
cssHeight = height;
dpr = nextDpr;
return true;
}
/** Resize the backing stores to the measured size. Wipes them, so all layers dirty. */
function applyCanvasSize() {
if (cssWidth === appliedWidth && cssHeight === appliedHeight && dpr === appliedDpr) return;
appliedWidth = cssWidth;
appliedHeight = cssHeight;
appliedDpr = dpr;
for (const name of LAYER_NAMES) {
const canvas = canvases[name];
canvas.width = Math.round(cssWidth * dpr);
canvas.height = Math.round(cssHeight * dpr);
canvas.style.width = `${cssWidth}px`;
canvas.style.height = `${cssHeight}px`;
}
for (const name of LAYER_NAMES) dirty[name] = true;
}
/** Apply the base dpr transform, then the world transform. */
function beginWorld(ctx) {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, cssWidth, cssHeight);
ctx.save();
ctx.translate(viewport.offsetX, viewport.offsetY);
ctx.scale(viewport.zoom, viewport.zoom);
}
function endWorld(ctx) {
ctx.restore();
}
function visibleRect() {
const rect = viewport.visibleWorldRect(cssWidth, cssHeight);
// A little margin so components straddling the edge are not clipped mid-body.
const margin = PITCH * 4;
return { x: rect.x - margin, y: rect.y - margin, w: rect.w + margin * 2, h: rect.h + margin * 2 };
}
function visibleBoards(view) {
if (!scene.circuit) return [];
return scene.circuit.boards.filter(board => rectsIntersect(boardBounds(board), view));
}
// --- Layer painters ---
function drawBoardLayer() {
const ctx = contexts.board;
beginWorld(ctx);
if (scene.circuit) {
const art = boardArt.get(viewport.zoom, palette.colors, palette.version);
const view = visibleRect();
for (const board of visibleBoards(view)) {
ctx.drawImage(art.surface, board.x, board.y, BOARD_WIDTH, BOARD_HEIGHT);
}
}
endWorld(ctx);
}
/** Net level code for a hole, or -1 when the simulation has nothing to say. */
function levelAtHole(hole) {
if (!scene.netLevels || !scene.netOfStrip || !hole) return -1;
const netId = scene.netOfStrip.get(stripKey(hole));
if (netId === undefined || netId < 0 || netId >= scene.netLevels.length) return -1;
return scene.netLevels[netId];
}
function drawStaticLayer() {
const ctx = contexts.static;
beginWorld(ctx);
if (scene.circuit) {
const view = visibleRect();
for (const component of scene.circuit.components) {
if (VOLATILE_TYPES.has(component.type)) continue;
const bounds = componentBounds(component, scene.boards);
if (bounds === null || !rectsIntersect(bounds, view)) continue;
drawComponent(ctx, component, scene.boards, palette.colors, {}, viewport.zoom);
}
}
endWorld(ctx);
}
function drawDynamicLayer() {
const ctx = contexts.dynamic;
beginWorld(ctx);
if (scene.circuit) {
const view = visibleRect();
for (const wire of scene.circuit.wires) {
const from = holeWorldPos(wire.from, scene.boards);
const to = holeWorldPos(wire.to, scene.boards);
if (!from || !to) continue;
const bounds = {
x: Math.min(from.x, to.x) - PITCH,
y: Math.min(from.y, to.y) - PITCH,
w: Math.abs(to.x - from.x) + PITCH * 2,
h: Math.abs(to.y - from.y) + PITCH * 2
};
if (!rectsIntersect(bounds, view)) continue;
let levelColor = null;
if (scene.simActive) {
const level = levelAtHole(wire.from);
if (level >= 0) levelColor = palette.levelColor(level);
}
drawWire(ctx, from, to, wire.color, {
levelColor,
shadow: palette.colors.wireShadow
});
}
for (const component of scene.circuit.components) {
if (!VOLATILE_TYPES.has(component.type)) continue;
const bounds = componentBounds(component, scene.boards);
if (bounds === null || !rectsIntersect(bounds, view)) continue;
drawComponent(ctx, component, scene.boards, palette.colors, {
brightness: scene.ledBrightness.get(component.uid) || 0,
burned: scene.burned.has(component.uid),
pressed: scene.pressed.has(component.uid)
}, viewport.zoom);
}
}
endWorld(ctx);
}
function drawOverlayLayer() {
const ctx = contexts.overlay;
beginWorld(ctx);
const colors = palette.colors;
// Selection rings around every selected component's pins.
for (const uid of scene.selection) {
const component = scene.circuit && scene.circuit.components.find(c => c.uid === uid);
if (component) {
const bounds = componentBounds(component, scene.boards);
if (bounds) {
ctx.strokeStyle = colors.selection;
ctx.lineWidth = Math.max(1.5 / viewport.zoom, PITCH * 0.07);
ctx.setLineDash([PITCH * 0.3, PITCH * 0.2]);
ctx.strokeRect(bounds.x, bounds.y, bounds.w, bounds.h);
ctx.setLineDash([]);
}
continue;
}
const wire = scene.circuit && scene.circuit.wires.find(w => w.uid === uid);
if (wire) {
const from = holeWorldPos(wire.from, scene.boards);
const to = holeWorldPos(wire.to, scene.boards);
if (from && to) {
ctx.strokeStyle = colors.selection;
ctx.lineWidth = PITCH * 0.34;
ctx.globalAlpha = 0.45;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(from.x, from.y);
ctx.lineTo(to.x, to.y);
ctx.stroke();
ctx.globalAlpha = 1;
}
}
}
// Components a warning named. Worth drawing rather than only listing: the
// whole difficulty with a self-shorted or burned-out part is that it looks
// correctly placed, so a text row alone leaves the user hunting for it.
if (scene.warnedUids && scene.warnedUids.size > 0 && scene.circuit) {
ctx.strokeStyle = colors.invalid;
ctx.lineWidth = Math.max(2 / viewport.zoom, PITCH * 0.09);
ctx.setLineDash([PITCH * 0.22, PITCH * 0.18]);
for (const uid of scene.warnedUids) {
const component = scene.circuit.components.find(c => c.uid === uid);
if (!component) continue;
const bounds = componentBounds(component, scene.boards);
if (bounds === null) continue;
const pad = PITCH * 0.18;
ctx.strokeRect(bounds.x - pad, bounds.y - pad, bounds.w + pad * 2, bounds.h + pad * 2);
}
ctx.setLineDash([]);
}
// Ghost of the component about to be placed.
if (scene.ghost && scene.ghost.component) {
ctx.globalAlpha = 0.55;
drawComponent(ctx, scene.ghost.component, scene.boards, colors, {}, viewport.zoom);
ctx.globalAlpha = 1;
if (!scene.ghost.valid) {
const bounds = componentBounds(scene.ghost.component, scene.boards);
if (bounds) {
ctx.strokeStyle = colors.invalid;
ctx.lineWidth = Math.max(1.5 / viewport.zoom, PITCH * 0.08);
ctx.strokeRect(bounds.x, bounds.y, bounds.w, bounds.h);
}
}
}
// Wire being dragged out.
if (scene.pendingWire) {
const from = holeWorldPos(scene.pendingWire.from, scene.boards);
if (from && scene.pendingWire.toPoint) {
ctx.strokeStyle = scene.pendingWire.color;
ctx.lineWidth = PITCH * 0.18;
ctx.globalAlpha = 0.85;
ctx.setLineDash([PITCH * 0.4, PITCH * 0.25]);
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(from.x, from.y);
ctx.lineTo(scene.pendingWire.toPoint.x, scene.pendingWire.toPoint.y);
ctx.stroke();
ctx.setLineDash([]);
ctx.globalAlpha = 1;
}
}
// Hovered hole, plus a soft wash over every other hole on the same strip so the
// electrical grouping is visible while wiring.
if (scene.hoverHole) {
const point = holeWorldPos(scene.hoverHole, scene.boards);
if (point) {
if (scene.hoverStripPoints) {
ctx.fillStyle = colors.hover;
ctx.globalAlpha = 0.18;
for (const p of scene.hoverStripPoints) {
ctx.beginPath();
ctx.arc(p.x, p.y, PITCH * 0.3, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
ctx.strokeStyle = colors.hover;
ctx.lineWidth = Math.max(1.5 / viewport.zoom, PITCH * 0.08);
ctx.beginPath();
ctx.arc(point.x, point.y, PITCH * 0.34, 0, Math.PI * 2);
ctx.stroke();
}
}
endWorld(ctx);
}
const PAINTERS = { board: drawBoardLayer, static: drawStaticLayer, dynamic: drawDynamicLayer, overlay: drawOverlayLayer };
// Layers whose painter has already thrown, so the failure is reported once rather
// than on every frame.
const reportedFailures = new Set();
function paint() {
frameHandle = null;
applyCanvasSize();
for (const name of LAYER_NAMES) {
if (!dirty[name]) continue;
try {
PAINTERS[name]();
// Cleared only on success. Marking clean before painting would leave a
// layer that threw permanently stale, so it could never recover even
// once the cause was gone.
dirty[name] = false;
} catch (error) {
if (!reportedFailures.has(name)) {
reportedFailures.add(name);
const message = error && error.message ? error.message : String(error);
if (typeof options.onPaintError === 'function') {
options.onPaintError(name, message);
}
}
// Stop rather than cascade: the layers below would paint over a gap.
break;
}
}
}
function schedule() {
if (frameHandle === null) frameHandle = requestAnimationFrame(paint);
}
function invalidate(...names) {
for (const name of names) dirty[name] = true;
schedule();
}
function invalidateAll() {
invalidate(...LAYER_NAMES);
}
const unsubscribeTheme = palette.subscribe(() => {
boardArt.invalidate();
invalidateAll();
});
return {
get canvas() { return canvases.overlay; },
get width() { return cssWidth; },
get height() { return cssHeight; },
/** Replace the scene and mark the given layers dirty. */
setScene(next, ...invalidated) {
scene = Object.assign(scene, next);
invalidate(...(invalidated.length > 0 ? invalidated : LAYER_NAMES));
},
resize() {
if (measureContainer()) invalidateAll();
},
invalidate,
invalidateAll,
/** Board artwork must be re-rasterized when the zoom bucket may have changed. */
viewportChanged() {
invalidate('board', 'static', 'dynamic', 'overlay');
},
destroy() {
if (frameHandle !== null) cancelAnimationFrame(frameHandle);
frameHandle = null;
unsubscribeTheme();
boardArt.invalidate();
for (const name of LAYER_NAMES) {
const canvas = canvases[name];
if (canvas.parentNode) canvas.parentNode.removeChild(canvas);
}
}
};
}
@@ -0,0 +1,74 @@
// Friendly wording for the server validator's machine-readable error tokens.
//
// The validator emits `path:reason` tokens (e.g. "components[3].props.color:unsupported")
// deliberately, so they stay machine-readable. Turning them into sentences belongs
// here, in the UI layer, and MUST fall back to the raw token for anything unrecognised
// - a new validator reason has to remain visible, not vanish.
const REASONS = Object.freeze(Object.assign(Object.create(null), {
malformed_json: 'could not be read',
exceeds_max_size: 'is larger than the 2 MB limit',
unsupported: 'has a value this build does not support',
unknown_property: 'has a property this build does not recognise',
not_an_object: 'is not shaped like a circuit element',
not_an_array: 'should be a list',
not_a_boolean: 'should be true or false',
wrong_length: 'has the wrong number of entries',
out_of_range: 'is outside the allowed range',
unknown_board: 'refers to a board that does not exist',
duplicate: 'is used more than once',
not_applicable: 'is not allowed on this component',
contradicts_anchor_row: 'does not match which side of the centre channel the part sits on',
footprint_off_board: 'would place part of the component off the edge of the board',
package_off_board: 'is too close to the edge for the package to fit',
not_valid_on_rail: 'is not a valid direction for a part in a power rail',
rail_pair_already_supplied: 'already has a 5V supply on that rail pair',
unsupported_version: 'was saved by a different version of the editor',
truncated: 'and more problems were found than can be listed'
}));
const PATHS = Object.freeze(Object.assign(Object.create(null), {
circuit: 'The circuit',
version: 'The circuit version',
boards: 'The boards list',
components: 'The components list',
wires: 'The wires list'
}));
function describePath(path) {
if (PATHS[path]) return PATHS[path];
// components[3].props.color -> "Component 4's colour"
const match = /^(components|wires|boards)\[(\d+)\](?:\.(.+))?$/.exec(path);
if (match) {
const noun = { components: 'Component', wires: 'Wire', boards: 'Board' }[match[1]];
const ordinal = Number(match[2]) + 1;
const field = match[3] ? ` (${match[3].replace(/\./g, ' ')})` : '';
return `${noun} ${ordinal}${field}`;
}
return path;
}
/**
* Turn one validator token into a sentence, or return it unchanged when it is not in
* the expected shape.
* @param {string} token
* @returns {string}
*/
export function describeServerError(token) {
if (typeof token !== 'string') return String(token);
const split = token.lastIndexOf(':');
if (split <= 0) return token;
const path = token.slice(0, split);
const reason = token.slice(split + 1);
const wording = REASONS[reason];
// Unrecognised reason: show the raw token so nothing is silently swallowed.
if (!wording) return token;
return `${describePath(path)} ${wording}.`;
}
/** Map a list of tokens, preserving order. */
export function describeServerErrors(tokens) {
return tokens.map(describeServerError);
}
@@ -0,0 +1,307 @@
// Client for the simulation worker.
//
// Speaks the engine protocol exactly. This module owns ALL knowledge of the worker
// message shapes; the rest of the editor sees plain callbacks and a small state
// object, so a protocol change lands in one file.
//
// Behaviours of the engine that the UI must not misread, per the engine pair:
// - The worker AUTO-PAUSES when the circuit settles. Frame silence is normal for a
// combinational circuit, not a hang. `settled` says so explicitly.
// - `input` works while paused; the worker settles the consequences and posts a
// frame immediately, so a button press updates the view without pressing run.
// - `run` can REFUSE: on a rail-to-rail short it posts a frame with halted:true and
// does not start. The UI must reflect that the run did not take.
// - A burned LED reports 0 mA, which is also what an off LED reports. Burnout is
// known ONLY from the one-shot `ledBurnout` warning, so it is latched here and
// cleared on load/reset.
const WORKER_URL = '/js/breadboard/engine/worker.js';
/** Current at which an LED is drawn at full brightness (also the overcurrent point). */
const FULL_BRIGHTNESS_MA = 20;
export function createSimClient(handlers = {}) {
let worker = null;
let available = false;
let loadError = null;
const state = {
running: false,
settled: false,
halted: false,
loaded: false,
netCount: 0,
simTimeNs: 0,
netOfStrip: new Map(),
pinNets: new Map(), // uid -> [netId per pin], -1 when unconnected
ledOrder: [],
netLevels: null,
ledCurrentMa: new Map(), // uid -> mA
ledBrightness: new Map(), // uid -> 0..1
burned: new Set(),
// Set by a worker `error`, cleared only when WE send a new load/reset. A failed
// load posts BOTH `error` and `loaded`, so this survives the `loaded` that
// follows and stops the failure being reported twice or wiped from the list.
engineError: null
};
function emit(name, ...args) {
const handler = handlers[name];
if (typeof handler === 'function') handler(...args);
}
function resetDerived() {
state.netOfStrip = new Map();
state.pinNets = new Map();
state.ledOrder = [];
state.netLevels = null;
state.ledCurrentMa = new Map();
state.ledBrightness = new Map();
state.burned = new Set();
state.running = false;
state.settled = false;
state.halted = false;
state.simTimeNs = 0;
}
function handleLoaded(message) {
const carriedError = state.engineError;
resetDerived();
state.engineError = carriedError;
state.loaded = true;
// The engine reports a failed load two ways: a preceding `error` message, and a
// `loadFailed` entry in these warnings with nets:0. Either means this is not a
// valid empty circuit, so check both rather than trusting nets===0.
const warnings = Array.isArray(message.warnings) ? message.warnings : [];
const loadFailed = warnings.some(w => w && w.kind === 'loadFailed');
state.netCount = typeof message.nets === 'number' ? message.nets : 0;
const index = message.netIndex || {};
if (index.strips && typeof index.strips === 'object') {
state.netOfStrip = new Map(Object.entries(index.strips));
}
if (index.components && typeof index.components === 'object') {
for (const [uid, nets] of Object.entries(index.components)) {
if (Array.isArray(nets)) state.pinNets.set(uid, nets);
}
}
state.ledOrder = Array.isArray(index.ledOrder) ? index.ledOrder.slice() : [];
if (loadFailed) state.halted = true;
emit('loaded', {
netCount: state.netCount,
warnings,
// The engine posts `loaded` even when the load failed, so that anything
// awaiting it is released. Tell the UI not to treat this as a clean start.
failed: carriedError !== null || loadFailed
});
}
function handleFrame(message) {
state.netLevels = message.netLevels instanceof Uint8Array ? message.netLevels : null;
state.simTimeNs = typeof message.simTimeNs === 'number' ? message.simTimeNs : state.simTimeNs;
// Additive fields beyond the spec; absent on an older engine, so default safely.
if (typeof message.running === 'boolean') state.running = message.running;
if (typeof message.settled === 'boolean') state.settled = message.settled;
if (typeof message.halted === 'boolean') state.halted = message.halted;
if (state.halted) state.running = false;
const currents = message.ledStates;
if (currents && currents.length >= 0) {
for (let i = 0; i < state.ledOrder.length && i < currents.length; i++) {
const uid = state.ledOrder[i];
const ma = currents[i];
state.ledCurrentMa.set(uid, ma);
// A burned LED also reports 0 mA, so the latched flag - not the
// current - decides whether it is drawn as dead.
state.ledBrightness.set(uid, state.burned.has(uid)
? 0
: Math.max(0, Math.min(1, ma / FULL_BRIGHTNESS_MA)));
}
}
emit('frame', state);
}
function handleWarning(message) {
if (message.kind === 'ledBurnout' && Array.isArray(message.uids)) {
for (const uid of message.uids) {
state.burned.add(uid);
state.ledBrightness.set(uid, 0);
}
}
// Unknown kinds are passed through untouched - the status surface renders them
// generically so a new engine warning needs no change here.
emit('warning', {
kind: typeof message.kind === 'string' ? message.kind : 'unknown',
uids: Array.isArray(message.uids) ? message.uids : [],
netId: message.netId,
detail: typeof message.detail === 'string' ? message.detail : ''
});
}
/**
* The engine threw internally (amendment A13). The worker is still alive but its
* state is not to be trusted, so stop the transport and tell the user - never
* leave the UI waiting on frames that will not come.
*/
function handleError(message) {
state.running = false;
state.halted = true;
const context = typeof message.context === 'string' && message.context.length > 0
? message.context : 'simulation';
const detail = typeof message.message === 'string' ? message.message : '';
state.engineError = { context, detail };
emit('warning', {
kind: 'engineError',
uids: Array.isArray(message.uids) ? message.uids : [],
netId: message.netId,
detail: detail ? `${context}: ${detail}` : context
});
emit('frame', state);
}
/**
* The Worker failed to start or threw at the top level. Separate from the engine's
* own `error` message, which is a structured report from a worker that is running.
*/
function onWorkerError(event) {
loadError = event.message || 'The simulation engine failed to start.';
available = false;
state.running = false;
emit('unavailable', loadError);
}
function onMessage(event) {
const message = event.data;
if (!message || typeof message.type !== 'string') return;
switch (message.type) {
case 'loaded': handleLoaded(message); break;
case 'frame': handleFrame(message); break;
case 'warning': handleWarning(message); break;
case 'error': handleError(message); break;
default:
// Forward-compatible, but NEVER silent about a failure: an unrecognised
// message that carries error-shaped fields is surfaced rather than
// dropped, because a swallowed error looks exactly like a hung worker.
if (/error|fail/i.test(message.type)
|| typeof message.message === 'string'
|| typeof message.context === 'string') {
handleError(message);
}
break;
}
}
function post(message) {
if (worker === null) return;
try {
worker.postMessage(message);
} catch (error) {
// Structured clone failed, so the message NEVER REACHED the worker: no
// loaded, no frame, and no engine-side `error` either, because the engine
// was never told. Without this the careful error plumbing is bypassed and
// the UI waits forever. Route it into the same failure path.
handleError({
context: message && message.type ? message.type : 'postMessage',
message: error && error.message ? error.message : String(error)
});
}
}
return {
state,
get available() { return available; },
get loadError() { return loadError; },
/**
* Start the worker. Failure is reported rather than thrown, so the editor
* still works without simulation.
* @returns {boolean} whether the worker started
*/
start() {
if (worker !== null) return true;
try {
worker = new Worker(WORKER_URL, { type: 'module' });
} catch (error) {
loadError = error && error.message ? error.message : String(error);
available = false;
emit('unavailable', loadError);
return false;
}
worker.addEventListener('message', onMessage);
worker.addEventListener('error', onWorkerError);
available = true;
return true;
},
/** Send a circuit for net extraction. Clears burnout and run state. */
load(circuit) {
resetDerived();
state.engineError = null;
state.loaded = false;
post({ type: 'load', circuit });
},
run() {
state.running = true; // optimistic; a halted frame corrects it
post({ type: 'run' });
},
pause() {
state.running = false;
post({ type: 'pause' });
},
step(count = 1) {
post({ type: 'step', count });
},
setSpeed(eventsPerSecond) {
post({ type: 'setSpeed', eventsPerSecond });
},
/** Button press/release. */
setButton(uid, pressed) {
post({ type: 'input', uid, value: pressed === true });
},
/** DIP toggle. `pin` is the SWITCH number 1..8, not the package pin. */
setSwitch(uid, pin, on) {
post({ type: 'input', uid, value: { pin, on: on === true } });
},
/** Re-load the last circuit, clearing LED burnout. */
reset() {
state.burned.clear();
state.ledBrightness.clear();
state.engineError = null;
state.halted = false;
post({ type: 'reset' });
},
/** Net level code for a strip key, or -1 when unknown. */
levelForStrip(stripKey) {
if (!state.netLevels) return -1;
const netId = state.netOfStrip.get(stripKey);
if (netId === undefined || netId < 0 || netId >= state.netLevels.length) return -1;
return state.netLevels[netId];
},
destroy() {
if (worker !== null) {
// Removed by name rather than relying on terminate() to take the
// listeners with it, so teardown does not depend on that detail.
worker.removeEventListener('message', onMessage);
worker.removeEventListener('error', onWorkerError);
worker.terminate();
worker = null;
}
available = false;
resetDerived();
}
};
}
export { FULL_BRIGHTNESS_MA };
@@ -0,0 +1,166 @@
// Status strip: transient messages and simulation warnings.
//
// Non-intrusive by design - nothing here blocks the canvas or steals focus. Every
// string that reaches the DOM goes through textContent, because these carry server
// error text and engine detail strings.
import { el, button, setText, clear, createListenerBag } from './dom.js';
/** How long an informational message stays before fading. Warnings persist. */
const INFO_TIMEOUT_MS = 3200;
/**
* Friendly text for the warning kinds the engine documents. Unknown kinds are rendered
* generically rather than dropped, so a new engine warning needs no change here
* (amendment A8).
*/
const WARNING_LABELS = Object.freeze(Object.assign(Object.create(null), {
contention: 'Contention — two outputs are driving the same net',
ledOvercurrent: 'LED over 20 mA',
ledBurnout: 'LED burned out',
shortCircuit: 'Short circuit across the supply',
oscillation: 'Oscillation — the circuit never settles',
floatingInput: 'Floating input',
unpoweredChip: 'Chip has no power',
invalidHole: 'Component is not on a valid hole',
duplicateUid: 'Duplicate component id',
unknownComponent: 'Unrecognised component',
unconnectedSupply: 'Supply is not connected to anything',
selfShorted: 'Both ends are on the same net',
floatingControl: 'Nothing is connected to the base or gate',
unlimitedBaseCurrent: 'Transistor base has no series resistor',
engineError: 'The simulation engine hit an internal error',
warningsSuppressed: 'Some warnings were coalesced'
}));
const SEVERITY = Object.freeze(Object.assign(Object.create(null), {
// A coalescing summary, not a circuit fault - it must not read as an error.
warningsSuppressed: 'info',
engineError: 'error',
ledBurnout: 'error',
shortCircuit: 'error',
contention: 'error',
oscillation: 'warn',
ledOvercurrent: 'warn',
unlimitedBaseCurrent: 'warn'
}));
function humanizeKind(kind) {
// "someUnknownKind" -> "Some unknown kind"
const spaced = String(kind).replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ');
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
}
export function createStatus(root) {
const bag = createListenerBag();
const messageList = el('div', { className: 'bb-status-messages' });
const warningList = el('div', { className: 'bb-status-warnings' });
const warningHeader = el('div', { className: 'bb-status-warnings-header' });
const warningTitle = el('span', { className: 'bb-status-warnings-title' });
const clearButton = button('Clear', { className: 'bb-btn bb-btn-ghost bb-btn-small' });
warningHeader.appendChild(warningTitle);
warningHeader.appendChild(clearButton);
const panel = el('div', {
className: 'bb-status',
attrs: { role: 'status', 'aria-live': 'polite' },
children: [messageList, warningList]
});
root.appendChild(panel);
/** kind -> { count, detail, uids, node } so repeats collapse instead of flooding. */
const warnings = new Map();
const timers = new Set();
function renderWarningHeader() {
if (warnings.size === 0) {
if (warningHeader.parentNode) warningList.removeChild(warningHeader);
warningList.classList.remove('is-visible');
return;
}
if (!warningHeader.parentNode) warningList.insertBefore(warningHeader, warningList.firstChild);
warningList.classList.add('is-visible');
setText(warningTitle, `${warnings.size} issue${warnings.size === 1 ? '' : 's'}`);
}
function message(text, kind) {
if (!text) return;
const node = el('div', { className: `bb-message bb-message-${kind}`, text });
messageList.appendChild(node);
// Newest first, and never let the list grow without bound.
while (messageList.childElementCount > 4) messageList.removeChild(messageList.firstChild);
if (kind === 'info') {
const timer = setTimeout(() => {
timers.delete(timer);
if (node.parentNode) node.parentNode.removeChild(node);
}, INFO_TIMEOUT_MS);
timers.add(timer);
}
}
bag.on(clearButton, 'click', () => {
warnings.clear();
clear(warningList);
renderWarningHeader();
});
return {
info(text) { message(text, 'info'); },
warn(text) { message(text, 'warn'); },
error(text) { message(text, 'error'); },
/** Show a list of messages, e.g. a server validation failure. */
errors(list) {
for (const text of list) message(text, 'error');
},
/**
* Record a simulation warning. Unknown kinds render generically - never
* dropped, never a crash.
*/
addWarning(warning) {
const kind = warning.kind || 'unknown';
const existing = warnings.get(kind);
if (existing) {
existing.count++;
setText(existing.countNode, `x${existing.count}`);
if (warning.detail) setText(existing.detailNode, warning.detail);
return;
}
const label = WARNING_LABELS[kind] || humanizeKind(kind);
const severity = SEVERITY[kind] || 'warn';
const titleNode = el('span', { className: 'bb-warning-title', text: label });
const countNode = el('span', { className: 'bb-warning-count', text: '' });
const detailNode = el('span', { className: 'bb-warning-detail', text: warning.detail || '' });
const uidsText = Array.isArray(warning.uids) && warning.uids.length > 0
? warning.uids.join(', ') : '';
const uidNode = el('span', { className: 'bb-warning-uids', text: uidsText });
const node = el('div', {
className: `bb-warning bb-warning-${severity}`,
children: [titleNode, countNode, detailNode, uidNode]
});
warningList.appendChild(node);
warnings.set(kind, { count: 1, node, countNode, detailNode });
renderWarningHeader();
},
/** Drop all simulation warnings, e.g. on a fresh load. */
clearWarnings() {
warnings.clear();
clear(warningList);
renderWarningHeader();
},
destroy() {
for (const timer of timers) clearTimeout(timer);
timers.clear();
bag.removeAll();
if (panel.parentNode) panel.parentNode.removeChild(panel);
}
};
}
@@ -0,0 +1,141 @@
// Canvas palette, sourced from CSS.
//
// Canvas cannot inherit CSS, so every colour the renderer draws is read out of a CSS
// custom property defined in breadboard.css. There are NO hardcoded colours in the
// renderer - change the look by editing the stylesheet, and both themes follow.
//
// site.css puts the dark palette on bare :root and overrides it under
// [data-theme="light"], so this reads whatever is currently in effect rather than
// assuming either. The site's theme customizer can also set custom properties inline
// on <html>, which is why the observer watches `style` as well as `data-theme`.
/** Every custom property the renderer needs, with a fallback if the sheet is missing. */
const TOKENS = Object.freeze({
boardFace: ['--bb-board-face', '#e8e6df'],
boardEdge: ['--bb-board-edge', '#c9c5b8'],
boardBevel: ['--bb-board-bevel', '#f5f3ee'],
channel: ['--bb-channel', '#d8d5cb'],
channelEdge: ['--bb-channel-edge', '#bdb9ac'],
hole: ['--bb-hole', '#3a3a3a'],
holeRim: ['--bb-hole-rim', '#b9b5a8'],
silk: ['--bb-silk', '#8a8578'],
silkStrong: ['--bb-silk-strong', '#5e5a50'],
railPlus: ['--bb-rail-plus', '#d24b4b'],
railMinus: ['--bb-rail-minus', '#4b6fd2'],
canvasBg: ['--bb-canvas-bg', '#1b1d21'],
grid: ['--bb-grid', '#2a2d33'],
hover: ['--bb-hover', '#3fb950'],
selection: ['--bb-selection', '#58a6ff'],
ghost: ['--bb-ghost', '#58a6ff'],
invalid: ['--bb-invalid', '#f85149'],
wireShadow: ['--bb-wire-shadow', 'rgba(0,0,0,0.35)'],
chipBody: ['--bb-chip-body', '#2b2b2f'],
chipLabel: ['--bb-chip-label', '#d8d8d8'],
chipPin: ['--bb-chip-pin', '#c8c8cc'],
resistorBody: ['--bb-resistor-body', '#d8c49a'],
resistorLead: ['--bb-resistor-lead', '#b0b0b0'],
diodeBody: ['--bb-diode-body', '#9aa7b4'],
diodeBand: ['--bb-diode-band', '#1c1c20'],
transistorBody: ['--bb-transistor-body', '#1f1f24'],
buttonBody: ['--bb-button-body', '#3a3a3e'],
buttonCap: ['--bb-button-cap', '#c9553f'],
buttonCapDown: ['--bb-button-cap-down', '#8d3a2b'],
dipBody: ['--bb-dip-body', '#2f4a8c'],
dipSwitchOn: ['--bb-dip-switch-on', '#f2f2f2'],
dipSwitchOff: ['--bb-dip-switch-off', '#8b8b90'],
supplyBody: ['--bb-supply-body', '#26303a'],
supplyText: ['--bb-supply-text', '#e6edf3'],
burned: ['--bb-burned', '#4a4a4a'],
levelLow: ['--bb-level-low', '#3b6ea5'],
levelHigh: ['--bb-level-high', '#e0483d'],
levelHiZ: ['--bb-level-hiz', '#7d7d85'],
levelWeakLow: ['--bb-level-weak-low', '#4f7fa8'],
levelWeakHigh: ['--bb-level-weak-high', '#d98a4a'],
levelContention: ['--bb-level-contention', '#ffcc00']
});
/**
* Net level codes from the engine's `frame` message, mapped to palette keys.
* Index is the Uint8Array value: 0=low 1=high 2=highZ 3=weakLow 4=weakHigh 5=contention
*/
export const LEVEL_KEYS = Object.freeze([
'levelLow', 'levelHigh', 'levelHiZ', 'levelWeakLow', 'levelWeakHigh', 'levelContention'
]);
/** Human-readable names for the same codes, for the status bar. */
export const LEVEL_NAMES = Object.freeze([
'low', 'high', 'high-Z', 'weak low', 'weak high', 'contention'
]);
/**
* Reads the palette from CSS and notifies subscribers when the theme changes.
* `version` increments on every change so cached bitmaps know to re-rasterize.
*/
export function createPalette(rootElement) {
const probe = rootElement || document.documentElement;
let colors = read();
let version = 0;
const subscribers = new Set();
function read() {
const computed = getComputedStyle(probe);
const next = {};
for (const [key, [prop, fallback]] of Object.entries(TOKENS)) {
const value = computed.getPropertyValue(prop).trim();
next[key] = value.length > 0 ? value : fallback;
}
return next;
}
function refresh() {
const next = read();
const changed = Object.keys(next).some(k => next[k] !== colors[k]);
if (!changed) return false;
colors = next;
version++;
for (const fn of subscribers) fn(colors, version);
return true;
}
// <html> carries both the data-theme attribute and any inline custom-property
// overrides written by the site's theme customizer.
const observer = new MutationObserver(refresh);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme', 'style', 'class']
});
return {
/** Current colours. Treat as immutable; re-read via .colors after a change. */
get colors() { return colors; },
/** Bumped whenever the palette changes - use as a cache key. */
get version() { return version; },
/** Colour for a net level code, falling back to high-Z for unknown codes. */
levelColor(code) {
const key = LEVEL_KEYS[code];
return colors[key === undefined ? 'levelHiZ' : key];
},
subscribe(fn) {
subscribers.add(fn);
return () => subscribers.delete(fn);
},
/** Force a re-read; returns whether anything changed. */
refresh,
destroy() {
observer.disconnect();
subscribers.clear();
}
};
}
/**
* Blend a CSS colour toward transparency for glow effects. Only handles the alpha
* channel, so it works with any colour syntax the browser accepts by delegating to
* globalAlpha at draw time instead of parsing.
*/
export function withAlpha(ctx, alpha, draw) {
const previous = ctx.globalAlpha;
ctx.globalAlpha = previous * alpha;
draw();
ctx.globalAlpha = previous;
}
@@ -0,0 +1,131 @@
// Top toolbar: project identity, save state, simulation transport, zoom.
import { el, button, setText, createListenerBag } from './dom.js';
/** Simulation speed presets, in engine events per second. */
const SPEED_STEPS = Object.freeze([1, 10, 100, 1000, 10000, 100000, 1000000, 10000000]);
const DEFAULT_SPEED_INDEX = 5;
function formatSpeed(eventsPerSecond) {
if (eventsPerSecond >= 1000000) return `${eventsPerSecond / 1000000} M events/s`;
if (eventsPerSecond >= 1000) return `${eventsPerSecond / 1000} k events/s`;
return `${eventsPerSecond} events/s`;
}
export function createToolbar(root, handlers) {
const bag = createListenerBag();
const title = el('h1', { className: 'bb-title', text: handlers.projectName || 'Breadboard' });
const saveState = el('span', { className: 'bb-save-state', text: 'Loading…' });
const saveButton = button('Save', { className: 'bb-btn bb-btn-primary', attrs: { 'aria-keyshortcuts': 'Control+S' } });
const runButton = button('Run', { className: 'bb-btn bb-btn-run', title: 'Start the simulation' });
const pauseButton = button('Pause', { className: 'bb-btn', title: 'Pause the simulation' });
const stepButton = button('Step', { className: 'bb-btn', title: 'Advance one event' });
const resetButton = button('Reset', { className: 'bb-btn', title: 'Reload the circuit and clear burned-out parts' });
const simState = el('span', { className: 'bb-sim-state', text: 'idle' });
const speedInput = el('input', {
className: 'bb-speed',
attrs: {
type: 'range', min: '0', max: String(SPEED_STEPS.length - 1),
step: '1', value: String(DEFAULT_SPEED_INDEX),
'aria-label': 'Simulation speed'
}
});
const speedLabel = el('span', { className: 'bb-speed-label', text: formatSpeed(SPEED_STEPS[DEFAULT_SPEED_INDEX]) });
const zoomOut = button('', { className: 'bb-btn bb-btn-icon', title: 'Zoom out' });
const zoomIn = button('+', { className: 'bb-btn bb-btn-icon', title: 'Zoom in' });
const zoomFit = button('Fit', { className: 'bb-btn', title: 'Fit all boards in view' });
const zoomLabel = el('span', { className: 'bb-zoom-label', text: '100%' });
const addBoardButton = button('Add board', { className: 'bb-btn' });
// Last state written to the DOM. setSimState is called on every simulation frame,
// and writing unchanged text/disabled flags still invalidates layout on a
// wrapping toolbar, so identical calls stop here.
let lastSimSignature = null;
const group = (className, children) => el('div', { className: `bb-toolbar-group ${className}`, children });
const bar = el('div', {
className: 'bb-toolbar',
children: [
group('bb-group-project', [title, saveState, saveButton]),
group('bb-group-sim', [runButton, pauseButton, stepButton, resetButton, simState]),
group('bb-group-speed', [speedLabel, speedInput]),
group('bb-group-view', [addBoardButton, zoomOut, zoomLabel, zoomIn, zoomFit])
]
});
root.appendChild(bar);
bag.on(saveButton, 'click', () => handlers.onSave());
bag.on(runButton, 'click', () => handlers.onRun());
bag.on(pauseButton, 'click', () => handlers.onPause());
bag.on(stepButton, 'click', () => handlers.onStep());
bag.on(resetButton, 'click', () => handlers.onReset());
bag.on(addBoardButton, 'click', () => handlers.onAddBoard());
bag.on(zoomIn, 'click', () => handlers.onZoom(1.25));
bag.on(zoomOut, 'click', () => handlers.onZoom(1 / 1.25));
bag.on(zoomFit, 'click', () => handlers.onZoomFit());
bag.on(speedInput, 'input', () => {
const speed = SPEED_STEPS[Number(speedInput.value)] || SPEED_STEPS[DEFAULT_SPEED_INDEX];
setText(speedLabel, formatSpeed(speed));
handlers.onSpeed(speed);
});
return {
get initialSpeed() { return SPEED_STEPS[DEFAULT_SPEED_INDEX]; },
setProjectName(name) {
setText(title, name || 'Breadboard');
},
/**
* Reflect save state.
* @param {'clean'|'dirty'|'saving'|'error'} kind
*/
setSaveState(kind, text) {
saveState.className = `bb-save-state bb-save-${kind}`;
setText(saveState, text);
saveButton.disabled = kind === 'saving' || kind === 'clean';
},
/**
* Reflect simulation state. `halted` means the engine refused to run - a
* rail-to-rail short - so the run button must visibly not take.
*/
setSimState({ available, running, settled, halted, loaded }) {
const signature = `${!!available}|${!!running}|${!!settled}|${!!halted}|${!!loaded}`;
if (signature === lastSimSignature) return;
lastSimSignature = signature;
runButton.disabled = !available || running || halted;
pauseButton.disabled = !available || !running;
stepButton.disabled = !available || running;
resetButton.disabled = !available;
speedInput.disabled = !available;
let label = 'idle';
if (!available) label = 'engine unavailable';
else if (halted) label = 'halted — fix the fault, then reset';
else if (running) label = 'running';
else if (settled) label = 'settled';
else if (loaded) label = 'paused';
simState.className = `bb-sim-state${halted ? ' bb-sim-halted' : ''}${running ? ' bb-sim-running' : ''}`;
setText(simState, label);
},
setZoom(zoom) {
setText(zoomLabel, `${Math.round(zoom * 100)}%`);
},
destroy() {
bag.removeAll();
if (bar.parentNode) bar.parentNode.removeChild(bar);
}
};
}
export { SPEED_STEPS };
@@ -0,0 +1,536 @@
// Pointer and keyboard interaction.
//
// All mouse coordinates here are SCREEN space (CSS pixels relative to the canvas), and
// are converted to world space through the viewport before touching any geometry.
// devicePixelRatio never appears - see the policy note in renderer.js.
//
// Modes:
// select click to select, drag a component to move it, drag empty space to pan
// wire drag from hole to hole
// place:<type> a ghost follows the cursor; click a hole to commit
// Space or the middle button pans in any mode.
import {
holeAtWorldPoint,
holeWorldPos,
holesInStrip,
sameHole,
UPPER_ROWS,
BOARD_WIDTH,
BOARD_HEIGHT,
PITCH
} from '../shared/board-geometry.js';
import { getComponentDef } from '../shared/component-registry.js';
import { componentPinHoles } from '../shared/component-pins.js';
import { componentBounds } from './component-art.js';
import { createListenerBag } from './dom.js';
/** Pointer travel (screen px) before a press becomes a drag rather than a click. */
const DRAG_THRESHOLD = 4;
// How close the cursor must be to a hole, in WORLD units, to snap onto it.
//
// board-geometry's HOLE_HIT_RADIUS is half a pitch, which leaves the corners of every
// cell dead - roughly a fifth of the board selects nothing. That is a UI feel decision
// rather than a geometry fact, so the editor picks its own, more generous radius here.
// At 0.7 the snap regions of neighbouring holes overlap slightly and nearest-hole wins,
// so there is effectively no dead space and no risk of snapping to a distant hole.
const HOLE_SNAP_RADIUS = PITCH * 0.7;
// WheelEvent.deltaY is only in pixels when deltaMode is 0. Firefox reports deltaMode 1
// (LINES, ~3 per notch) where Chrome reports 0 (PIXELS, ~100 per notch) - a ~32x
// difference that makes an un-normalized zoom unusable outside Chromium. Normalizing at
// the event boundary keeps viewport.js in pixels and unaware of DOM event quirks, the
// same way it is kept unaware of devicePixelRatio.
const WHEEL_LINE_PX = 16;
const WHEEL_PAGE_FALLBACK_PX = 400;
/**
* The row a DIP-style package should anchor to for a hole the user clicked: the near
* side of the centre channel. Uses the shared row grouping rather than comparing row
* letters, so it cannot drift if the row alphabet ever changes.
*/
function channelSideRow(hole) {
return UPPER_ROWS.indexOf(hole.row) !== -1 ? 'e' : 'f';
}
function pointInRect(x, y, rect) {
return rect !== null && x >= rect.x && x <= rect.x + rect.w && y >= rect.y && y <= rect.y + rect.h;
}
export function createTools(options) {
const { canvas, viewport, state, renderer, sim, status, onSceneChange, onSelectionChange } = options;
const bag = createListenerBag();
let tool = { kind: 'select', type: null };
let wireColor = options.initialWireColor;
let spaceHeld = false;
// Transient interaction state
let gesture = null; // { kind, ... }
let hoverHole = null;
let hoverComponentUid = null;
// Cached canvas rect. pointermove is bound to `window` and needs the rect twice per
// event, and a high-poll mouse fires far more often than once per frame - reading it
// live forces a layout every time. Resize and scroll invalidate it explicitly; the
// TTL is the backstop for a layout shift that moves the canvas without either, so
// the rect is never more than RECT_TTL_MS stale.
let canvasRect = null;
let canvasRectAt = 0;
const RECT_TTL_MS = 250;
function bounds() {
const now = performance.now();
if (canvasRect === null || now - canvasRectAt > RECT_TTL_MS) {
canvasRect = canvas.getBoundingClientRect();
canvasRectAt = now;
}
return canvasRect;
}
function screenPoint(event) {
const rect = bounds();
return { x: event.clientX - rect.left, y: event.clientY - rect.top };
}
/** Whether a pointer event happened over the canvas itself. */
function isOverCanvas(event) {
const rect = bounds();
return event.clientX >= rect.left && event.clientX <= rect.right
&& event.clientY >= rect.top && event.clientY <= rect.bottom;
}
function worldPoint(event) {
const point = screenPoint(event);
return viewport.screenToWorld(point.x, point.y);
}
function holeAt(world) {
// Radius is in world units, so snapping feels the same at every zoom.
return holeAtWorldPoint(world.x, world.y, state.circuit.boards, HOLE_SNAP_RADIUS);
}
function componentAt(world) {
// Only boards under the cursor can hold the component under the cursor, so a
// cheap board test skips resolving pin geometry for everything elsewhere.
const nearbyBoards = new Set(
state.circuit.boards
.filter(b => world.x >= b.x - PITCH && world.x <= b.x + BOARD_WIDTH + PITCH
&& world.y >= b.y - PITCH && world.y <= b.y + BOARD_HEIGHT + PITCH)
.map(b => b.uid)
);
if (nearbyBoards.size === 0) return null;
// Topmost first, so later components win where they overlap.
const components = state.circuit.components;
for (let i = components.length - 1; i >= 0; i--) {
const component = components[i];
const boardUid = component.anchor ? component.anchor.board
: (component.props ? component.props.board : null);
if (boardUid !== null && boardUid !== undefined && !nearbyBoards.has(boardUid)) continue;
if (pointInRect(world.x, world.y, componentBounds(component, state.boards))) {
return component;
}
}
return null;
}
/** Which DIP switch lever (1..8) is under a world point, or 0. */
function switchAt(component, world) {
if (component.type !== 'dipSwitch8') return 0;
const pins = componentPinHoles(component);
let best = 0;
let bestDistance = PITCH * 0.6;
for (let k = 0; k < 8; k++) {
const hole = pins[k] && pins[k].hole;
if (!hole) continue;
const point = holeWorldPos(hole, state.boards);
if (!point) continue;
const distance = Math.abs(point.x - world.x);
if (distance < bestDistance) {
bestDistance = distance;
best = k + 1;
}
}
return best;
}
function ghostFor(hole) {
if (tool.kind !== 'place' || hole === null) return null;
const def = getComponentDef(tool.type);
if (def === null) return null;
let anchor = hole;
// DIP-style packages must straddle the channel, so snap onto the nearest of
// rows e/f rather than refusing every other row.
if (def.dipStyle && hole.kind === 'main') {
anchor = Object.assign({}, hole, { row: channelSideRow(hole) });
}
const component = state.buildComponent(tool.type, anchor, ghostExtraProps(hole));
if (component === null) return null;
const pins = componentPinHoles(component);
const valid = pins.length > 0 && pins.every(p => p.hole !== null);
return { component, valid, anchor };
}
function ghostExtraProps(hole) {
if (tool.type === 'powerSupply5V' && hole) {
return { board: hole.board, side: hole.kind === 'rail' ? (hole.rail.startsWith('top') ? 'top' : 'bottom') : 'top' };
}
if (tool.type === 'resistor' && hole) {
// Second terminal defaults four columns along, which the user then edits.
const to = Object.assign({}, hole);
if (to.kind === 'main') to.col = Math.min(63, to.col + 4);
else to.index = Math.min(50, to.index + 4);
return { to };
}
return undefined;
}
function publishScene(...layers) {
const ghost = tool.kind === 'place' ? ghostFor(hoverHole) : null;
renderer.setScene({
circuit: state.circuit,
boards: state.boards,
selection: state.selection,
hoverHole,
hoverStripPoints: hoverHole
? holesInStrip(hoverHole).map(h => holeWorldPos(h, state.boards)).filter(Boolean)
: null,
ghost,
pendingWire: gesture && gesture.kind === 'wire'
? { from: gesture.from, toPoint: gesture.toPoint, color: wireColor }
: null,
pressed: state.pressed
}, ...(layers.length > 0 ? layers : ['overlay']));
if (onSceneChange) onSceneChange();
}
// --- Gesture handlers ---
function beginPan(event) {
const point = screenPoint(event);
gesture = { kind: 'pan', lastX: point.x, lastY: point.y };
canvas.style.cursor = 'grabbing';
}
function tryPlace(hole) {
const ghost = ghostFor(hole);
if (ghost === null) return;
if (!ghost.valid) {
status.warn(`A ${getComponentDef(tool.type).label} does not fit there.`);
return;
}
const result = state.addComponent(tool.type, ghost.component.anchor, ghostExtraProps(hole));
if (!result.ok) {
status.warn(result.reason);
return;
}
for (const warning of result.warnings || []) status.warn(warning);
status.info(`Placed ${getComponentDef(tool.type).label}.`);
}
function onPointerDown(event) {
if (event.button !== 0 && event.button !== 1) return;
canvas.focus();
const world = worldPoint(event);
const screen = screenPoint(event);
if (event.button === 1 || spaceHeld) {
beginPan(event);
event.preventDefault();
return;
}
const hole = holeAt(world);
if (tool.kind === 'place') {
tryPlace(hole);
return;
}
if (tool.kind === 'wire') {
if (hole === null) {
beginPan(event);
return;
}
gesture = { kind: 'wire', from: hole, toPoint: world };
publishScene();
return;
}
// --- select mode ---
const component = componentAt(world);
// Interactive parts respond to a plain click, so the user can drive the
// simulation without switching tools.
if (component) {
if (component.type === 'pushButton') {
gesture = { kind: 'button', uid: component.uid };
state.setPressed(component.uid, true);
sim.setButton(component.uid, true);
publishScene('dynamic', 'overlay');
return;
}
if (component.type === 'dipSwitch8') {
const switchNumber = switchAt(component, world);
if (switchNumber > 0) {
const on = state.toggleSwitch(component.uid, switchNumber);
if (on !== null) {
sim.setSwitch(component.uid, switchNumber, on);
status.info(`Switch ${switchNumber} ${on ? 'on' : 'off'}.`);
}
return;
}
}
state.select(component.uid, event.shiftKey);
if (onSelectionChange) onSelectionChange();
const def = getComponentDef(component.type);
gesture = {
kind: 'maybeMove',
uid: component.uid,
movable: !def.anchorless,
startX: screen.x,
startY: screen.y
};
return;
}
const wire = hole ? state.wireAtHole(hole) : null;
if (wire) {
state.select(wire.uid, event.shiftKey);
if (onSelectionChange) onSelectionChange();
publishScene();
return;
}
if (!event.shiftKey) {
state.clearSelection();
if (onSelectionChange) onSelectionChange();
}
beginPan(event);
}
function onPointerMove(event) {
const world = worldPoint(event);
const screen = screenPoint(event);
if (gesture && gesture.kind === 'pan') {
viewport.panBy(screen.x - gesture.lastX, screen.y - gesture.lastY);
gesture.lastX = screen.x;
gesture.lastY = screen.y;
renderer.viewportChanged();
return;
}
if (gesture && gesture.kind === 'wire') {
gesture.toPoint = world;
const hole = holeAt(world);
// Snap the preview onto a hole when one is near.
if (hole !== null) {
const snapped = holeWorldPos(hole, state.boards);
if (snapped) gesture.toPoint = snapped;
}
hoverHole = hole;
publishScene();
return;
}
if (gesture && gesture.kind === 'maybeMove') {
const travelled = Math.hypot(screen.x - gesture.startX, screen.y - gesture.startY);
if (travelled > DRAG_THRESHOLD && gesture.movable) {
gesture = { kind: 'move', uid: gesture.uid };
} else {
return;
}
}
if (gesture && gesture.kind === 'move') {
const hole = holeAt(world);
if (hole !== null) {
const component = state.circuit.components.find(c => c.uid === gesture.uid);
if (component) {
const def = getComponentDef(component.type);
let anchor = hole;
if (def.dipStyle && hole.kind === 'main') {
anchor = Object.assign({}, hole, { row: channelSideRow(hole) });
}
if (!sameHole(component.anchor, anchor)) state.moveComponent(gesture.uid, anchor);
}
}
return;
}
if (gesture && gesture.kind === 'button') return;
// Idle hover. pointermove is bound to `window` so a drag that leaves the canvas
// keeps tracking - but that also means this fires for every mouse move anywhere
// on the page. Hit-testing is O(components), so bail out before doing any of it
// when the pointer is not actually over the canvas.
if (!isOverCanvas(event)) {
if (hoverHole !== null || hoverComponentUid !== null) {
hoverHole = null;
hoverComponentUid = null;
publishScene();
}
return;
}
const hole = holeAt(world);
const component = componentAt(world);
const componentUid = component ? component.uid : null;
const holeChanged = (hole === null) !== (hoverHole === null)
|| (hole !== null && hoverHole !== null && !sameHole(hole, hoverHole));
if (!holeChanged && componentUid === hoverComponentUid) return;
hoverHole = hole;
hoverComponentUid = componentUid;
canvas.style.cursor = cursorFor(hole, component);
publishScene();
}
function cursorFor(hole, component) {
if (spaceHeld) return 'grab';
if (tool.kind === 'place') return 'copy';
if (tool.kind === 'wire') return hole ? 'crosshair' : 'default';
if (component) {
if (component.type === 'pushButton' || component.type === 'dipSwitch8') return 'pointer';
return 'move';
}
return 'default';
}
function onPointerUp(event) {
if (!gesture) return;
if (gesture.kind === 'wire') {
const hole = holeAt(worldPoint(event));
if (hole !== null && !sameHole(hole, gesture.from)) {
const result = state.addWire(gesture.from, hole, wireColor);
if (!result.ok) status.warn(result.reason);
}
} else if (gesture.kind === 'button') {
state.setPressed(gesture.uid, false);
sim.setButton(gesture.uid, false);
}
const wasPan = gesture.kind === 'pan';
gesture = null;
canvas.style.cursor = wasPan ? 'default' : canvas.style.cursor;
publishScene('dynamic', 'overlay');
}
function onPointerLeave() {
if (hoverHole === null && hoverComponentUid === null) return;
hoverHole = null;
hoverComponentUid = null;
publishScene();
}
/** Wheel delta in pixels, whatever unit the browser reported it in. */
function wheelDeltaPixels(event) {
if (event.deltaMode === 1) return event.deltaY * WHEEL_LINE_PX;
if (event.deltaMode === 2) {
return event.deltaY * (canvas.clientHeight || WHEEL_PAGE_FALLBACK_PX);
}
return event.deltaY;
}
function onWheel(event) {
event.preventDefault();
const point = screenPoint(event);
viewport.zoomByWheel(point.x, point.y, wheelDeltaPixels(event));
renderer.viewportChanged();
if (onSceneChange) onSceneChange();
}
function onKeyDown(event) {
if (event.key === ' ' && !spaceHeld) {
spaceHeld = true;
canvas.style.cursor = 'grab';
event.preventDefault();
return;
}
if (event.key === 'Escape') {
if (gesture && gesture.kind === 'wire') {
gesture = null;
status.info('Wire cancelled.');
} else if (tool.kind !== 'select') {
options.setTool({ kind: 'select', type: null });
} else {
state.clearSelection();
if (onSelectionChange) onSelectionChange();
}
publishScene();
return;
}
if (event.key === 'Delete' || event.key === 'Backspace') {
const removed = state.deleteSelected();
if (removed > 0) {
status.info(`Deleted ${removed} item${removed === 1 ? '' : 's'}.`);
if (onSelectionChange) onSelectionChange();
}
event.preventDefault();
return;
}
if (event.key === 'r' || event.key === 'R') {
for (const uid of [...state.selection]) {
const result = state.rotateComponent(uid);
if (!result.ok && result.reason) status.warn(result.reason);
}
return;
}
if (event.key === '+' || event.key === '=') {
viewport.zoomAtCenter(renderer.width, renderer.height, 1.2);
renderer.viewportChanged();
} else if (event.key === '-' || event.key === '_') {
viewport.zoomAtCenter(renderer.width, renderer.height, 1 / 1.2);
renderer.viewportChanged();
}
}
function onKeyUp(event) {
if (event.key === ' ') {
spaceHeld = false;
canvas.style.cursor = 'default';
}
}
const invalidateRect = () => { canvasRect = null; };
bag.on(window, 'resize', invalidateRect);
// Capture, so an ancestor scrolling the canvas out from under us also counts.
bag.on(window, 'scroll', invalidateRect, true);
bag.on(canvas, 'pointerdown', onPointerDown);
bag.on(window, 'pointermove', onPointerMove);
bag.on(window, 'pointerup', onPointerUp);
bag.on(canvas, 'pointerleave', onPointerLeave);
bag.on(canvas, 'wheel', onWheel, { passive: false });
bag.on(canvas, 'keydown', onKeyDown);
bag.on(canvas, 'keyup', onKeyUp);
bag.on(canvas, 'contextmenu', e => e.preventDefault());
return {
get tool() { return tool; },
setTool(next) {
tool = next;
gesture = null;
canvas.style.cursor = cursorFor(hoverHole, null);
publishScene();
},
get wireColor() { return wireColor; },
setWireColor(color) {
wireColor = color;
for (const uid of [...state.selection]) state.setWireColor(uid, color);
publishScene('dynamic', 'overlay');
},
refresh: publishScene,
/** Drop the cached canvas rect. Call whenever the canvas may have moved. */
invalidateRect,
destroy() {
bag.removeAll();
}
};
}
@@ -0,0 +1,136 @@
// Pan / zoom state and the conversions between coordinate spaces.
//
// THREE SPACES, and every function here is named for the one it works in:
// screen space - CSS pixels relative to the canvas element's top-left. This is what
// mouse events give us (after subtracting the bounding rect).
// world space - the shared coordinate system all boards live in. board.x/board.y
// and board-geometry's world positions are in this space.
// board space - a single board's own frame. board-geometry owns it; nothing here
// touches it.
//
// The mapping is: screen = world * zoom + offset
// world = (screen - offset) / zoom
//
// devicePixelRatio is deliberately NOT part of this. It is a property of the canvas
// backing store, applied by the renderer as the base transform before the world
// transform, so every number in this file is in CSS pixels. See renderer.js.
/** Zoom limits. Below MIN a board is a few pixels tall; above MAX holes are huge. */
export const MIN_ZOOM = 0.15;
export const MAX_ZOOM = 6;
const ZOOM_STEP = 1.0015; // per unit of wheel deltaY
export function createViewport() {
let zoom = 1;
let offsetX = 0;
let offsetY = 0;
function clampZoom(value) {
return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, value));
}
return {
get zoom() { return zoom; },
get offsetX() { return offsetX; },
get offsetY() { return offsetY; },
/** Screen point (CSS px, canvas-relative) -> world point. */
screenToWorld(screenX, screenY) {
return { x: (screenX - offsetX) / zoom, y: (screenY - offsetY) / zoom };
},
/** World point -> screen point (CSS px, canvas-relative). */
worldToScreen(worldX, worldY) {
return { x: worldX * zoom + offsetX, y: worldY * zoom + offsetY };
},
/** Convert a screen-space distance to world units. */
screenToWorldDistance(distance) {
return distance / zoom;
},
/** Move the view by a screen-space delta (a drag). */
panBy(screenDX, screenDY) {
offsetX += screenDX;
offsetY += screenDY;
},
/**
* Zoom about a fixed screen point, so the world point under the cursor stays
* put. This is the whole trick to a wheel zoom that feels right.
*/
zoomAt(screenX, screenY, factor) {
const next = clampZoom(zoom * factor);
if (next === zoom) return;
const worldX = (screenX - offsetX) / zoom;
const worldY = (screenY - offsetY) / zoom;
zoom = next;
offsetX = screenX - worldX * zoom;
offsetY = screenY - worldY * zoom;
},
/** Wheel handler helper: converts a wheel delta into a zoom factor. */
zoomByWheel(screenX, screenY, deltaY) {
this.zoomAt(screenX, screenY, Math.pow(ZOOM_STEP, -deltaY));
},
/** Zoom about the centre of a viewport of the given CSS-pixel size. */
zoomAtCenter(width, height, factor) {
this.zoomAt(width / 2, height / 2, factor);
},
/** Set zoom directly, keeping the viewport centre fixed. */
setZoom(value, width, height) {
const next = clampZoom(value);
this.zoomAt(width / 2, height / 2, next / zoom);
},
/**
* Frame a world-space rect in a viewport of the given CSS-pixel size.
* @param {{x:number,y:number,w:number,h:number}} bounds
*/
fit(bounds, width, height, padding = 40) {
if (width <= 0 || height <= 0 || bounds.w <= 0 || bounds.h <= 0) return;
const scale = Math.min(
(width - padding * 2) / bounds.w,
(height - padding * 2) / bounds.h
);
zoom = clampZoom(scale);
offsetX = width / 2 - (bounds.x + bounds.w / 2) * zoom;
offsetY = height / 2 - (bounds.y + bounds.h / 2) * zoom;
},
/** World-space rect currently visible in a viewport of the given size. */
visibleWorldRect(width, height) {
const topLeft = this.screenToWorld(0, 0);
const bottomRight = this.screenToWorld(width, height);
return {
x: topLeft.x,
y: topLeft.y,
w: bottomRight.x - topLeft.x,
h: bottomRight.y - topLeft.y
};
},
/** Serializable state, for persisting the view between sessions. */
toJSON() {
return { zoom, offsetX, offsetY };
},
/** Restore from toJSON(). Ignores anything malformed. */
restore(state) {
if (!state || typeof state !== 'object') return false;
if (![state.zoom, state.offsetX, state.offsetY].every(Number.isFinite)) return false;
zoom = clampZoom(state.zoom);
offsetX = state.offsetX;
offsetY = state.offsetY;
return true;
}
};
}
/** True when two world-space rects overlap - used to cull off-screen boards. */
export function rectsIntersect(a, b) {
return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
}
@@ -0,0 +1,96 @@
/**
* Shared constants for the breadboard simulation engine.
*
* IMMUTABILITY BOUNDARY (read this before editing anything in engine/):
* - constants.js, drive.js, union-find.js, net-builder.js and every model's
* pin/description table are PURE: no mutation, no I/O, safe to call from
* anywhere and freely testable.
* - net-state.js, event-queue.js and simulation.js own the HOT PATH. They use
* typed arrays and controlled in-place mutation on purpose. Nothing outside
* those three files may mutate their buffers; they expose accessor methods.
*/
/** Wire-format net level codes. These exact numbers go to the UI in `frame.netLevels`. */
export const LEVEL_LOW = 0;
export const LEVEL_HIGH = 1;
export const LEVEL_HIGHZ = 2;
export const LEVEL_WEAK_LOW = 3;
export const LEVEL_WEAK_HIGH = 4;
export const LEVEL_CONTENTION = 5;
/** Drive strengths, ordered. A higher strength always wins over a lower one. */
export const STRENGTH_HIGHZ = 0;
export const STRENGTH_WEAK = 1;
export const STRENGTH_STRONG = 2;
export const STRENGTH_SUPPLY = 3;
export const VALUE_LOW = 0;
export const VALUE_HIGH = 1;
/**
* What a chip input samples when its net is neither a clean low nor a clean
* high. A floating 74HC input is physically indeterminate, so the engine
* refuses to guess: it propagates UNKNOWN through three-valued logic and a
* gate whose output cannot be determined releases its driver to high-Z.
*/
export const INPUT_LOW = 0;
export const INPUT_HIGH = 1;
export const INPUT_UNKNOWN = 2;
/** Maps a net level code to what a chip input pin reads off it. */
export const LEVEL_TO_INPUT = new Uint8Array([
INPUT_LOW, // LEVEL_LOW
INPUT_HIGH, // LEVEL_HIGH
INPUT_UNKNOWN, // LEVEL_HIGHZ -- floating, indeterminate
INPUT_LOW, // LEVEL_WEAK_LOW -- a pull-down still reads as a low
INPUT_HIGH, // LEVEL_WEAK_HIGH -- a pull-up still reads as a high
INPUT_UNKNOWN, // LEVEL_CONTENTION
]);
export const SUPPLY_VOLTAGE = 5.0;
/** Nominal voltage a net sits at, by level code. NaN means "indeterminate". */
export const LEVEL_VOLTAGE = new Float64Array([
0.0, // LEVEL_LOW
SUPPLY_VOLTAGE, // LEVEL_HIGH
NaN, // LEVEL_HIGHZ
0.0, // LEVEL_WEAK_LOW
SUPPLY_VOLTAGE, // LEVEL_WEAK_HIGH
NaN, // LEVEL_CONTENTION
]);
/** Sentinel for "this pin is not connected to any net". */
export const NO_NET = -1;
/** Default gate propagation delay, ns. Per-type values live in each model. */
export const DEFAULT_DELAY_NS = 10;
/**
* Guards against zero-delay feedback loops (e.g. an inverter wired to itself
* through a component with no delay). Exceeding this at a single sim instant
* raises a warning and pauses instead of hanging the worker.
*/
export const MAX_EVENTS_PER_INSTANT = 100000;
/** LED current thresholds, amps. */
export const LED_WARN_CURRENT = 0.020;
export const LED_BURNOUT_CURRENT = 0.050;
/**
* Resistance an LED presents when nothing limits it. Real LEDs have a few ohms
* of bulk resistance; the point of the small number is that a resistor-less LED
* across 5 V computes a burnout-level current, which is what really happens.
*/
export const LED_INTRINSIC_OHMS = 10;
/** Forward voltage by LED colour. */
export const LED_FORWARD_VOLTAGE = Object.freeze({
red: 1.8,
yellow: 2.1,
orange: 2.0,
green: 2.1,
blue: 3.0,
white: 3.2,
});
export const DEFAULT_LED_FORWARD_VOLTAGE = 2.0;
@@ -0,0 +1,128 @@
/**
* Multi-strength signal resolution the reference (pure) implementation.
*
* A net's state is the fold of every driver attached to it. For that fold to be
* well defined the combine operator MUST be commutative and associative, so the
* answer cannot depend on driver order. It is NOT possible to get that by
* folding over the six wire-format level codes directly:
*
* (weakLow . weakHigh) . strongHigh vs weakLow . (weakHigh . strongHigh)
*
* If "weakLow . weakHigh" collapsed to `contention` and contention were
* absorbing, the left side would be `contention` while the right side is
* `strongHigh` and the right side is the physically correct answer, because a
* strong driver really does overpower a pull-up/pull-down pair. Contention is
* therefore NOT absorbing across strengths.
*
* The fix is to fold in a richer domain and project to a level code only at the
* end. A drive is a pair (strength, valueMask) where valueMask is a bitset over
* {0,1}. Combining takes the stronger drive, or unions the value masks when the
* strengths tie. That is a lexicographic semilattice: commutative, associative,
* idempotent, with high-Z as the identity element.
*
* A drive is packed into one small integer so the whole fold is branch-light and
* allocation-free: packed = strength * 4 + valueMask.
*/
import {
LEVEL_LOW,
LEVEL_HIGH,
LEVEL_HIGHZ,
LEVEL_WEAK_LOW,
LEVEL_WEAK_HIGH,
LEVEL_CONTENTION,
STRENGTH_HIGHZ,
STRENGTH_WEAK,
STRENGTH_STRONG,
STRENGTH_SUPPLY,
VALUE_LOW,
VALUE_HIGH,
} from './constants.js';
export const MASK_NONE = 0;
export const MASK_LOW = 1;
export const MASK_HIGH = 2;
export const MASK_BOTH = 3;
/** The identity element of the fold: drives nothing. */
export const DRIVE_HIGHZ = STRENGTH_HIGHZ * 4 + MASK_NONE;
/** Packs a (strength, value) pair into a drive. */
export function makeDrive(strength, value) {
if (strength === STRENGTH_HIGHZ) return DRIVE_HIGHZ;
return strength * 4 + (value === VALUE_HIGH ? MASK_HIGH : MASK_LOW);
}
export function driveStrength(drive) {
return drive >> 2;
}
export function driveMask(drive) {
return drive & 3;
}
/**
* The monoid operator. Commutative, associative, idempotent; DRIVE_HIGHZ is the
* identity. Pure.
*/
export function combineDrive(a, b) {
const sa = a >> 2;
const sb = b >> 2;
if (sa > sb) return a;
if (sb > sa) return b;
return sa * 4 + ((a & 3) | (b & 3));
}
/** Folds a list of packed drives. Pure; order-independent by construction. */
export function foldDrives(drives) {
let acc = DRIVE_HIGHZ;
for (let i = 0; i < drives.length; i++) acc = combineDrive(acc, drives[i]);
return acc;
}
/**
* Projects a folded drive onto the six wire-format level codes.
*
* Decisions the spec left open, pinned here:
* - strong beats an opposing weak outright, and raises no warning: that is
* just a pull-up being overdriven, the single most common breadboard idiom.
* - a pull-up fighting a pull-down (weakLow + weakHigh, no stronger driver)
* yields LEVEL_CONTENTION as a level, but raises NO contention warning
* the spec scopes that warning to conflicting *strong* drivers, and a
* resistor divider is not a fault. See `driveFault` below.
* - high-Z is the identity: high-Z combined with anything is that thing.
*/
export function driveToLevel(drive) {
const strength = drive >> 2;
const mask = drive & 3;
if (strength === STRENGTH_HIGHZ || mask === MASK_NONE) return LEVEL_HIGHZ;
if (mask === MASK_BOTH) return LEVEL_CONTENTION;
if (strength === STRENGTH_WEAK) return mask === MASK_HIGH ? LEVEL_WEAK_HIGH : LEVEL_WEAK_LOW;
return mask === MASK_HIGH ? LEVEL_HIGH : LEVEL_LOW;
}
export const FAULT_NONE = 0;
export const FAULT_CONTENTION = 1;
export const FAULT_SHORT_CIRCUIT = 2;
/**
* Classifies a folded drive as a fault, given whether any strong driver of the
* losing polarity is also present.
*
* - two supply drivers of opposite polarity on one net is rail+ tied to rail-:
* a short circuit, which pauses the sim.
* - two strong drivers of opposite polarity is ordinary output contention.
* - a chip output fighting a supply rail is also contention: supply wins the
* level, but the chip is still sinking or sourcing into a rail.
*/
export function driveFault(drive, opposingStrongPresent) {
const strength = drive >> 2;
const mask = drive & 3;
if (mask === MASK_BOTH) {
return strength === STRENGTH_SUPPLY ? FAULT_SHORT_CIRCUIT : strength === STRENGTH_WEAK ? FAULT_NONE : FAULT_CONTENTION;
}
if (strength === STRENGTH_SUPPLY && opposingStrongPresent) return FAULT_CONTENTION;
return FAULT_NONE;
}
export { STRENGTH_HIGHZ, STRENGTH_WEAK, STRENGTH_STRONG, STRENGTH_SUPPLY, VALUE_LOW, VALUE_HIGH };
@@ -0,0 +1,141 @@
/**
* Binary min-heap event queue, struct-of-arrays over typed arrays.
*
* MUTABLE HOT PATH. No object is allocated per event: an event is six parallel
* slots, and `pop()` writes the popped event into scalar fields on the queue
* rather than returning a record.
*
* Ordering key is the pair (timeNs, seq). A binary heap is not stable, so
* equal-time events would otherwise pop in an order that shifts when unrelated
* events are inserted; `seq` is a monotonically increasing insertion counter
* that makes ties resolve in insertion order. Simulation output is therefore
* reproducible regardless of the order components appear in the document.
*
* `seq` is a float64 counter, not int32: at a few million events per second a
* 32-bit counter wraps in under half an hour of wall time and the ordering
* silently inverts. Float64 counts exactly to 2^53.
*/
/** Schedule a driver change: target = driverId, arg = packed drive code. */
export const EVENT_DRIVE = 0;
/** Wake a device with no input change: target = device index, arg = timer id. */
export const EVENT_TIMER = 1;
export class EventQueue {
constructor(capacity = 1024) {
this.length = 0;
this.seqCounter = 0;
this.time = new Float64Array(capacity);
this.seq = new Float64Array(capacity);
this.kind = new Uint8Array(capacity);
this.target = new Int32Array(capacity);
this.arg = new Int32Array(capacity);
this.gen = new Int32Array(capacity);
// Fields written by pop(). Read them immediately; the next pop overwrites.
this.outTime = 0;
this.outKind = 0;
this.outTarget = 0;
this.outArg = 0;
this.outGen = 0;
}
get size() {
return this.length;
}
push(timeNs, kind, target, arg, gen) {
if (this.length === this.time.length) this.#grow();
let i = this.length++;
const seq = this.seqCounter++;
const time = this.time;
const seqs = this.seq;
// Sift up, moving the hole rather than swapping pairs.
while (i > 0) {
const parent = (i - 1) >> 1;
const pt = time[parent];
if (pt < timeNs || (pt === timeNs && seqs[parent] < seq)) break;
this.#copy(parent, i);
i = parent;
}
time[i] = timeNs;
seqs[i] = seq;
this.kind[i] = kind;
this.target[i] = target;
this.arg[i] = arg;
this.gen[i] = gen;
}
/** Time of the earliest event, or Infinity when empty. */
peekTime() {
return this.length === 0 ? Infinity : this.time[0];
}
/** Pops the earliest event into the out* fields. Returns false when empty. */
pop() {
if (this.length === 0) return false;
this.outTime = this.time[0];
this.outKind = this.kind[0];
this.outTarget = this.target[0];
this.outArg = this.arg[0];
this.outGen = this.gen[0];
const last = --this.length;
if (last === 0) return true;
const time = this.time;
const seqs = this.seq;
const lastTime = time[last];
const lastSeq = seqs[last];
let i = 0;
for (;;) {
const left = i * 2 + 1;
if (left >= last) break;
const right = left + 1;
let child = left;
if (right < last) {
const lt = time[left];
const rt = time[right];
if (rt < lt || (rt === lt && seqs[right] < seqs[left])) child = right;
}
const ct = time[child];
if (lastTime < ct || (lastTime === ct && lastSeq < seqs[child])) break;
this.#copy(child, i);
i = child;
}
this.#copy(last, i);
return true;
}
#copy(from, to) {
this.time[to] = this.time[from];
this.seq[to] = this.seq[from];
this.kind[to] = this.kind[from];
this.target[to] = this.target[from];
this.arg[to] = this.arg[from];
this.gen[to] = this.gen[from];
}
#grow() {
const capacity = this.time.length * 2;
const time = new Float64Array(capacity);
time.set(this.time);
const seq = new Float64Array(capacity);
seq.set(this.seq);
const kind = new Uint8Array(capacity);
kind.set(this.kind);
const target = new Int32Array(capacity);
target.set(this.target);
const arg = new Int32Array(capacity);
arg.set(this.arg);
const gen = new Int32Array(capacity);
gen.set(this.gen);
this.time = time;
this.seq = seq;
this.kind = kind;
this.target = target;
this.arg = arg;
this.gen = gen;
}
}
@@ -0,0 +1,49 @@
/**
* Bidirectional conduction between two pins of one component.
*
* Shared by every element that opens and closes a channel: mechanical contacts
* (push button, DIP switch) and the transistors. Conduction is NOT a dynamic
* merge of the two nets re-running union-find every time a contact moves
* would be slow, and it would invalidate the net index the UI is handed once at
* load. Each side simply drives the other with what it sees.
*
* Conduction preserves STRENGTH, unlike a resistor: a closed contact between
* the two power rails has to hand SUPPLY strength across so the short reads as
* a short circuit and not as garden-variety contention. The same is true of a
* saturated transistor, which is why it destroys itself in real life.
*
* The source net is always read EXCLUDING the element's own contribution to it,
* or a closed channel would read back the value it is itself asserting and
* latch onto it forever after the real source went away.
*
* Every caller passes a non-zero delay. It is a loop breaker rather than a
* physical figure: two elements wired in a ring would otherwise conduct round
* it at zero delay forever and trip the delta-cycle guard.
*/
import { STRENGTH_HIGHZ, VALUE_LOW } from '../constants.js';
import { driveStrength, driveMask, MASK_LOW, MASK_HIGH } from '../drive.js';
/** Passes `fromPin`'s net through to `toPin` at full strength, or opens the channel. */
export function conduct(ctx, inst, fromPin, toPin, closed, delayNs) {
if (!closed) {
ctx.drive(inst, toPin, STRENGTH_HIGHZ, VALUE_LOW, delayNs);
return;
}
const drive = ctx.driveExcludingSelf(inst, fromPin);
const strength = driveStrength(drive);
const mask = driveMask(drive);
// A source that is itself unresolved (both polarities present) passes
// nothing: there is no single value to conduct.
if (strength === STRENGTH_HIGHZ || (mask !== MASK_LOW && mask !== MASK_HIGH)) {
ctx.drive(inst, toPin, STRENGTH_HIGHZ, VALUE_LOW, delayNs);
return;
}
ctx.drive(inst, toPin, strength, mask === MASK_HIGH ? 1 : 0, delayNs);
}
/** Refreshes both directions of the bridge between `a` and `b`. */
export function refreshBridge(ctx, inst, a, b, closed, delayNs) {
conduct(ctx, inst, a, b, closed, delayNs);
conduct(ctx, inst, b, a, closed, delayNs);
}
@@ -0,0 +1,62 @@
/**
* Diode. Pin 1 is the anode, pin 2 the banded cathode.
*
* A one-way conductor: forward biased it passes the anode's drive through to
* the cathode at full strength (conduction.js), reverse biased it passes
* nothing, and it NEVER drives its anode in either state. That asymmetry is the
* whole part it is what makes diode-OR steering, polarity protection and a
* freewheel path across an inductive load behave differently from a wire.
*
* Forward bias is decided from the cathode read EXCLUDING this diode's own
* contribution, for the same reason the transistors do it: a diode charging an
* otherwise-floating node would otherwise see the node it had just pulled high,
* conclude it was no longer forward biased, release, and oscillate.
*
* The cathode is treated as blocking whenever it is ALREADY high not only
* when it is driven high by something stronger. Two supplies steered into one
* node through a diode each is the ordinary case, and neither diode should
* report conducting into the other's output.
*
* LIMIT OF THIS MODEL. There is no forward voltage drop, because there is no
* voltage between 0 V and 5 V to drop it to (see resistor.js). A diode here is
* a switch that only closes one way; a chain of them does not stack up 0.7 V a
* time, and a diode cannot be used as a voltage reference. Reverse breakdown is
* not modelled either, so a Zener cannot be built from one. Forward voltage
* DOES matter for an LED, and lives in the LED's own model.
*/
import { LEVEL_HIGH, LEVEL_WEAK_HIGH } from '../constants.js';
import { defineModel } from './registry.js';
import { conduct } from './conduction.js';
/** Loop breaker, as everywhere else conduction is instantaneous in reality. */
const DIODE_DELAY_NS = 1;
const PIN_ANODE = 0;
const PIN_CATHODE = 1;
function isHigh(level) {
return level === LEVEL_HIGH || level === LEVEL_WEAK_HIGH;
}
function refreshBias(ctx, inst) {
// The anode needs no exclusion: a diode never drives its own anode, so its
// driver there is high-Z and contributes nothing to exclude.
const forward = isHigh(ctx.level(inst, PIN_ANODE)) && !isHigh(ctx.levelExcludingSelf(inst, PIN_CATHODE));
conduct(ctx, inst, PIN_ANODE, PIN_CATHODE, forward, DIODE_DELAY_NS);
}
defineModel({
type: 'diode',
pinCount: 2,
delayNs: DIODE_DELAY_NS,
functionalPins: [[1, 2]],
init(ctx, inst) {
refreshBias(ctx, inst);
},
evaluate(ctx, inst) {
refreshBias(ctx, inst);
},
});
@@ -0,0 +1,17 @@
/**
* Registers every model shipped in milestone 1.
*
* Importing this module is the only thing needed to populate the registry;
* each model file self-registers via defineModel(). Adding a counter, register,
* EEPROM or NE555 later means adding a file and one import line here.
*/
import './logic-ic.js';
import './supply.js';
import './resistor.js';
import './switches.js';
import './led.js';
import './diode.js';
import './transistor.js';
export { registry, getModel, knownTypes, defineModel, WAKE_INIT, WAKE_PIN, WAKE_TIMER } from './registry.js';
@@ -0,0 +1,130 @@
/**
* LED. Pin 1 is the anode, pin 2 the cathode.
*
* An LED is a load, not a driver: it presents high-Z on both pins and never
* asserts a level. What it does is compute forward current so the UI can show
* brightness, and so overcurrent and burnout can be reported.
*
* I = (Vanode - Vcathode - Vf) / Rseries
*
* Rseries is the dominant current-limiting resistor on each side plus the LED's
* own bulk resistance. "Dominant" means the smallest resistor touching that
* net, which is the right answer for the normal one-resistor-per-side case and
* a sane approximation otherwise a full nodal solve is out of scope for a
* logic simulator.
*
* The bulk resistance is what makes a resistor-less LED behave the way it does
* in real life: 5 V straight across a red LED computes ~320 mA and burns it
* out, which is exactly the mistake a beginner makes on a real breadboard and
* exactly what this simulator exists to show them.
*
* Burnout latches. A burned LED conducts nothing and stays burned until the
* circuit is reloaded or explicitly reset the spec calls for a persistent
* flag, not a transient warning.
*/
import {
LEVEL_VOLTAGE,
LED_FORWARD_VOLTAGE,
DEFAULT_LED_FORWARD_VOLTAGE,
LED_INTRINSIC_OHMS,
LED_WARN_CURRENT,
LED_BURNOUT_CURRENT,
} from '../constants.js';
import { defineModel } from './registry.js';
const PIN_ANODE = 0;
const PIN_CATHODE = 1;
function forwardVoltageOf(component) {
const colour = String(component?.props?.color ?? 'red').toLowerCase();
return LED_FORWARD_VOLTAGE[colour] ?? DEFAULT_LED_FORWARD_VOLTAGE;
}
/**
* Forward current in amps, or 0 when the LED is off, reverse-biased, burned, or
* sitting on a net whose level is indeterminate. Pure exported so the current
* maths can be tested without standing up a whole simulation.
*/
export function ledCurrent(anodeLevel, cathodeLevel, forwardVoltage, seriesOhms) {
const vAnode = LEVEL_VOLTAGE[anodeLevel];
const vCathode = LEVEL_VOLTAGE[cathodeLevel];
if (Number.isNaN(vAnode) || Number.isNaN(vCathode)) return 0;
const across = vAnode - vCathode - forwardVoltage;
if (across <= 0) return 0;
return across / seriesOhms;
}
defineModel({
type: 'led',
pinCount: 2,
delayNs: 0,
functionalPins: [[1, 2]],
createState(component) {
return {
forwardVoltage: forwardVoltageOf(component),
seriesOhms: LED_INTRINSIC_OHMS,
current: 0,
burned: false,
warnedOvercurrent: false,
};
},
init(ctx, inst) {
const anodeSide = ctx.dominantSeriesOhms(inst.pins[PIN_ANODE]);
const cathodeSide = ctx.dominantSeriesOhms(inst.pins[PIN_CATHODE]);
inst.state.seriesOhms = anodeSide + cathodeSide + LED_INTRINSIC_OHMS;
this.evaluate(ctx, inst, { reason: 0, pin: -1 });
},
evaluate(ctx, inst) {
const state = inst.state;
if (state.burned) {
ctx.setLedCurrent(inst, 0);
return;
}
const current = ledCurrent(
ctx.level(inst, PIN_ANODE),
ctx.level(inst, PIN_CATHODE),
state.forwardVoltage,
state.seriesOhms,
);
if (current === state.current) return;
state.current = current;
if (current > LED_BURNOUT_CURRENT) {
state.burned = true;
state.current = 0;
ctx.setLedCurrent(inst, 0);
ctx.warn(
'ledBurnout',
[inst.uid],
inst.pins[PIN_ANODE],
`LED ${inst.uid} drew ${(current * 1000).toFixed(0)} mA through ${state.seriesOhms.toFixed(0)} ohms and burned out`,
);
return;
}
ctx.setLedCurrent(inst, current);
if (current > LED_WARN_CURRENT && !state.warnedOvercurrent) {
// Latched for the lifetime of this load, NOT reset when the current
// falls back. An earlier version cleared the latch on every falling
// edge, which sounds like "one warning per excursion" but means a
// blinking LED re-warns on every single cycle — hundreds of
// thousands of messages a second on a running oscillator. Being
// over-current once is the fact worth reporting; the user does not
// need telling again on the next blink.
state.warnedOvercurrent = true;
ctx.warn(
'ledOvercurrent',
[inst.uid],
inst.pins[PIN_ANODE],
`LED ${inst.uid} is drawing ${(current * 1000).toFixed(1)} mA (over ${LED_WARN_CURRENT * 1000} mA)`,
);
}
},
});
export const LED_TYPE = 'led';
@@ -0,0 +1,186 @@
/**
* 74HC combinational logic ICs, 14-pin DIP.
*
* PINOUTS ARE THE WHOLE POINT OF THIS FILE a wrong one is invisible in a
* passing test suite and poisons every circuit built on it. Pin numbers below
* are 1-based, exactly as printed on a datasheet, and are converted to 0-based
* indexes at registration. Two that catch people out:
*
* - 74HC02 is NOT the '00 layout. The NOR gate's OUTPUT comes first:
* pin 1 is 1Y, not 1A. Cloning the '00 table here yields a chip that looks
* right and behaves wrong.
* - 74HC30's eight inputs are NOT pins 1-8. Pins 9, 10 and 13 are no-connects
* and inputs G and H live on 11 and 12.
*
* '00, '08, '32 and '86 really do share one layout
* (1A 1B 1Y 2A 2B 2Y GND 3Y 3A 3B 4Y 4A 4B VCC), so that table is written once.
*
* Propagation delays are typical tPD at 5 V, 25 C from the NXP/TI 74HC data
* sheets, rounded to whole ns.
*/
import {
INPUT_UNKNOWN,
LEVEL_TO_INPUT,
LEVEL_HIGH,
LEVEL_WEAK_HIGH,
LEVEL_LOW,
LEVEL_WEAK_LOW,
STRENGTH_STRONG,
STRENGTH_HIGHZ,
VALUE_HIGH,
VALUE_LOW,
} from '../constants.js';
import { defineModel, WAKE_PIN } from './registry.js';
import { applyOp, OP_AND, OP_NAND, OP_OR, OP_NOR, OP_XOR, OP_NOT } from './logic.js';
/** Quad 2-input gate layout shared by 74HC00, '08, '32 and '86. */
const QUAD_2IN_PINS = [
{ out: 3, ins: [1, 2] },
{ out: 6, ins: [4, 5] },
{ out: 8, ins: [9, 10] },
{ out: 11, ins: [12, 13] },
];
/** 74HC02 quad 2-input NOR — outputs first. */
const QUAD_NOR_PINS = [
{ out: 1, ins: [2, 3] },
{ out: 4, ins: [5, 6] },
{ out: 10, ins: [8, 9] },
{ out: 13, ins: [11, 12] },
];
/** 74HC04 hex inverter. */
const HEX_INV_PINS = [
{ out: 2, ins: [1] },
{ out: 4, ins: [3] },
{ out: 6, ins: [5] },
{ out: 8, ins: [9] },
{ out: 10, ins: [11] },
{ out: 12, ins: [13] },
];
/** 74HC30 single 8-input NAND. Pins 9, 10 and 13 are no-connects. */
const NAND8_PINS = [{ out: 8, ins: [1, 2, 3, 4, 5, 6, 11, 12] }];
const IC_TABLE = [
{ type: '74HC00', op: OP_NAND, gates: QUAD_2IN_PINS, delayNs: 9 },
{ type: '74HC02', op: OP_NOR, gates: QUAD_NOR_PINS, delayNs: 9 },
{ type: '74HC04', op: OP_NOT, gates: HEX_INV_PINS, delayNs: 8 },
{ type: '74HC08', op: OP_AND, gates: QUAD_2IN_PINS, delayNs: 9 },
{ type: '74HC32', op: OP_OR, gates: QUAD_2IN_PINS, delayNs: 9 },
{ type: '74HC86', op: OP_XOR, gates: QUAD_2IN_PINS, delayNs: 12 },
{ type: '74HC30', op: OP_NAND, gates: NAND8_PINS, delayNs: 12 },
];
const VCC_PIN = 14;
const GND_PIN = 7;
const PIN_COUNT = 14;
const NO_GATE = 255;
/** Scratch buffer for gate inputs. Single-threaded worker, so one is enough. */
const inputScratch = new Uint8Array(8);
function isPowered(ctx, inst) {
const vcc = ctx.level(inst, inst.model.vccIndex);
const gnd = ctx.level(inst, inst.model.gndIndex);
return (vcc === LEVEL_HIGH || vcc === LEVEL_WEAK_HIGH) && (gnd === LEVEL_LOW || gnd === LEVEL_WEAK_LOW);
}
function evaluateGate(ctx, inst, gateIndex, powered) {
const gate = inst.model.gateList[gateIndex];
if (!powered) {
// An unpowered chip drives nothing. Its outputs are high-Z, not low.
ctx.drive(inst, gate.out, STRENGTH_HIGHZ, VALUE_LOW, inst.delayNs);
return;
}
const ins = gate.ins;
for (let i = 0; i < ins.length; i++) inputScratch[i] = LEVEL_TO_INPUT[ctx.level(inst, ins[i])];
const result = applyOp(inst.model.op, inputScratch, ins.length);
if (result === INPUT_UNKNOWN) {
// Indeterminate output: release the pin rather than invent a level, so
// the unknown keeps propagating instead of being laundered into a 0.
ctx.drive(inst, gate.out, STRENGTH_HIGHZ, VALUE_LOW, inst.delayNs);
return;
}
ctx.drive(inst, gate.out, STRENGTH_STRONG, result === 1 ? VALUE_HIGH : VALUE_LOW, inst.delayNs);
}
function evaluateAll(ctx, inst) {
const powered = isPowered(ctx, inst);
const gates = inst.model.gateList;
for (let g = 0; g < gates.length; g++) evaluateGate(ctx, inst, g, powered);
}
for (const entry of IC_TABLE) {
const gateList = entry.gates.map((gate) => ({
out: gate.out - 1,
ins: gate.ins.map((p) => p - 1),
}));
// pin -> the one gate it feeds, for O(1) wake dispatch.
const gateOfPin = new Uint8Array(PIN_COUNT).fill(NO_GATE);
for (let g = 0; g < gateList.length; g++) {
for (const pin of gateList[g].ins) gateOfPin[pin] = g;
}
defineModel({
type: entry.type,
pinCount: PIN_COUNT,
delayNs: entry.delayNs,
vcc: VCC_PIN,
gnd: GND_PIN,
vccIndex: VCC_PIN - 1,
gndIndex: GND_PIN - 1,
op: entry.op,
gateList,
gateOfPin,
outputPins: gateList.map((g) => g.out),
inputPins: gateList.flatMap((g) => g.ins),
init(ctx, inst) {
if (!isPowered(ctx, inst)) {
ctx.staticWarning(
'unpoweredChip',
`${inst.type} ${inst.uid}: pin ${VCC_PIN} (VCC) / pin ${GND_PIN} (GND) are not tied to 5V and ground`,
[inst.uid],
);
}
// An input is floating when nothing else in the circuit shares its
// net: no wire, no other pin, so nothing can ever drive it. That is
// different from an input that simply has not been driven yet at
// load time, which is normal and must not warn.
for (const gate of inst.model.gateList) {
for (const pin of gate.ins) {
if (inst.pins[pin] < 0 || ctx.netListenerCount(inst.pins[pin]) <= 1) {
ctx.staticWarning('floatingInput', `${inst.type} ${inst.uid}: pin ${pin + 1} is not connected to anything`, [
inst.uid,
]);
}
}
}
},
evaluate(ctx, inst, wake) {
// Anything that is not a pin change — init, a self-scheduled timer,
// or any wake reason added later — re-evaluates the whole chip.
// Testing `=== WAKE_INIT` instead left WAKE_TIMER falling through to
// the pin path with wake.pin === -1, where gateOfPin[-1] is
// `undefined`, `undefined === NO_GATE` is false, and gateList
// [undefined].ins throws. ctx.scheduleSelf is a public API, so that
// was reachable by any model author following the registry docs.
if (wake.reason !== WAKE_PIN) {
evaluateAll(ctx, inst);
return;
}
const pin = wake.pin;
if (pin === inst.model.vccIndex || pin === inst.model.gndIndex) {
evaluateAll(ctx, inst);
return;
}
const gate = inst.model.gateOfPin[pin];
if (!(gate >= 0) || gate === NO_GATE) return;
evaluateGate(ctx, inst, gate, isPowered(ctx, inst));
},
});
}
@@ -0,0 +1,80 @@
/**
* Three-valued combinational logic primitives.
*
* Pure. Inputs and outputs are INPUT_LOW / INPUT_HIGH / INPUT_UNKNOWN.
*
* Unknown is not "assume zero". A 74HC input left floating is genuinely
* indeterminate, and quietly calling it a low produces a simulation that looks
* plausible and lies. Instead unknown propagates, EXCEPT where a controlling
* input settles the result on its own: a NAND with one input low outputs high
* no matter what the other input is doing, and reporting that as unknown would
* be its own kind of wrong.
*/
import { INPUT_LOW, INPUT_HIGH, INPUT_UNKNOWN } from '../constants.js';
export function andOf(values, count) {
let unknown = false;
for (let i = 0; i < count; i++) {
const v = values[i];
if (v === INPUT_LOW) return INPUT_LOW; // controlling value
if (v === INPUT_UNKNOWN) unknown = true;
}
return unknown ? INPUT_UNKNOWN : INPUT_HIGH;
}
export function orOf(values, count) {
let unknown = false;
for (let i = 0; i < count; i++) {
const v = values[i];
if (v === INPUT_HIGH) return INPUT_HIGH; // controlling value
if (v === INPUT_UNKNOWN) unknown = true;
}
return unknown ? INPUT_UNKNOWN : INPUT_LOW;
}
export function xorOf(values, count) {
let parity = 0;
for (let i = 0; i < count; i++) {
const v = values[i];
if (v === INPUT_UNKNOWN) return INPUT_UNKNOWN; // XOR has no controlling value
parity ^= v;
}
return parity === 1 ? INPUT_HIGH : INPUT_LOW;
}
export function invert(value) {
return value === INPUT_UNKNOWN ? INPUT_UNKNOWN : value === INPUT_LOW ? INPUT_HIGH : INPUT_LOW;
}
export const OP_AND = 0;
export const OP_NAND = 1;
export const OP_OR = 2;
export const OP_NOR = 3;
export const OP_XOR = 4;
export const OP_XNOR = 5;
export const OP_NOT = 6;
export const OP_BUF = 7;
export function applyOp(op, values, count) {
switch (op) {
case OP_AND:
return andOf(values, count);
case OP_NAND:
return invert(andOf(values, count));
case OP_OR:
return orOf(values, count);
case OP_NOR:
return invert(orOf(values, count));
case OP_XOR:
return xorOf(values, count);
case OP_XNOR:
return invert(xorOf(values, count));
case OP_NOT:
return invert(values[0]);
case OP_BUF:
return values[0];
default:
return INPUT_UNKNOWN;
}
}
@@ -0,0 +1,146 @@
/**
* Component model registry.
*
* Deliberately shaped for parts this milestone does NOT ship. Milestone 1 is
* seven combinational chips plus passives, which would be satisfied by a much
* flatter `{ pins, fn }` shape but counters, registers, EEPROMs and the NE555
* are next, and the spec forbids reshaping the registry for them. So the model
* interface already carries the four things stateful parts need:
*
* (a) per-instance mutable state. `createState(component)` runs once per
* placed instance at load. State never lives in a closure captured at
* definition time, or every instance of a part would share one counter.
* (b) self-scheduled wake-ups. `ctx.scheduleSelf(inst, deltaNs, timerId)`
* fires `evaluate` with no input change an astable NE555 or a clock
* source is just a model that reschedules itself.
* (c) edge sensitivity. `evaluate` receives a wake record carrying which pin
* moved and both its previous and new level, so a flip-flop can test for
* a rising edge instead of re-deriving state from levels.
* (d) runtime pin direction. Every connected pin owns a driver from load, and
* a model may drive or release any pin at any moment. Direction is never
* baked into a static descriptor, so a tri-state data bus (an EEPROM
* releasing its data pins when /OE is high) needs no new machinery.
*
* A model definition is frozen, shared by all instances of the type, and pure
* apart from the mutation it performs through `ctx`.
*
* @typedef {object} ModelDefinition
* @property {string} type
* @property {number} pinCount
* @property {number} [delayNs] default propagation delay
* @property {number} [vcc] 1-based power pin, if the part needs power
* @property {number} [gnd] 1-based ground pin
* @property {Array<[number,number]>} [ties] permanent internal shorts, 1-based
* @property {(component:object)=>object|null} [createState]
* @property {(ctx:object, inst:object)=>void} [init]
* @property {(ctx:object, inst:object, wake:object)=>void} evaluate
* @property {(component:object)=>Array<{pin:*,hole:*}>} [pinHoles] override
* @property {Array<[number,number]>} [functionalPins] 1-based pin pairs that must
* land on DIFFERENT nets for the part to do anything (an LED's two legs, a
* resistor's two ends). Used only for the load-time self-short check.
*/
import { componentPinHoles, staticPinBonds, componentSelfShorts, switchablePinBonds } from '../../shared/component-pins.js';
import { DEFAULT_DELAY_NS } from '../constants.js';
/** Why a model is being evaluated. */
export const WAKE_INIT = 0;
export const WAKE_PIN = 1;
export const WAKE_TIMER = 2;
const definitions = new Map();
/** Registers a model definition. Later registration of the same type replaces it. */
export function defineModel(definition) {
if (!definition || typeof definition.type !== 'string') throw new Error('model definition needs a type');
if (typeof definition.evaluate !== 'function') throw new Error(`model ${definition.type} needs evaluate()`);
const frozen = Object.freeze({
delayNs: DEFAULT_DELAY_NS,
pinCount: 0,
ties: [],
...definition,
});
definitions.set(frozen.type, frozen);
return frozen;
}
export function getModel(type) {
return definitions.get(type) ?? null;
}
export function knownTypes() {
return [...definitions.keys()];
}
/**
* The façade the rest of the engine uses. Kept as an object rather than loose
* functions so tests can substitute a registry with a subset of models.
*/
export const registry = Object.freeze({
get: getModel,
knownTypes,
/** Pin -> hole mapping. Models may override; default is the shared helper. */
pinHoles(component) {
const model = getModel(component?.type);
if (model?.pinHoles) return model.pinHoles(component);
try {
return componentPinHoles(component) ?? [];
} catch {
return [];
}
},
/**
* Pin pairs the BOARD GEOMETRY puts in a single strip, 0-based.
*
* shared/component-pins.js already excludes pins that are tied by design, so
* a push button's 1-2 and 3-4 do not appear here verified against every
* component type rather than assumed.
*/
selfShorts(component) {
const pairs = componentSelfShorts(component) ?? [];
return pairs.map(([a, b]) => [a - 1, b - 1]);
},
/**
* Pin pairs that are supposed to be SEPARATE nets for the component to have
* any effect, 0-based: the two sides of every switch contact, and the two
* terminals of a two-terminal passive. Design ties are subtracted, so a
* permanently-bonded pair is never reported.
*/
functionalPairs(component) {
const model = getModel(component?.type);
if (!model) return [];
const tied = new Set(
(staticPinBonds(component) ?? []).map(([a, b]) => (a < b ? `${a}-${b}` : `${b}-${a}`)),
);
const pairs = [];
const add = (a, b) => {
const key = a < b ? `${a}-${b}` : `${b}-${a}`;
if (!tied.has(key)) pairs.push([a - 1, b - 1]);
};
for (const bond of switchablePinBonds(component) ?? []) {
if (Array.isArray(bond?.pins) && bond.pins.length === 2) add(bond.pins[0], bond.pins[1]);
}
for (const [a, b] of model.functionalPins ?? []) add(a, b);
return pairs;
},
/**
* Permanent internal shorts, as 0-based pin index pairs.
*
* shared/component-pins.js is the authority here it also owns the pin
* geometry, and a push button's two A-side pins land on different columns,
* so getting these out of step with the layout would silently break every
* button. A model's own `ties` are only a fallback for types the shared
* registry does not know about.
*/
internalTies(component) {
const shared = staticPinBonds(component);
if (shared && shared.length > 0) return shared.map(([a, b]) => [a - 1, b - 1]);
const model = getModel(component?.type);
if (!model || model.ties.length === 0) return [];
return model.ties.map(([a, b]) => [a - 1, b - 1]);
},
});
@@ -0,0 +1,78 @@
/**
* Resistor a bidirectional weak pass element.
*
* A resistor is not unioned into its neighbours' nets at load, because then a
* pull-up would be indistinguishable from a wire. Instead each end drives the
* other end WEAKLY with whatever it sees. That single rule covers all three
* ways resistors get used on a breadboard:
*
* - pull-up / pull-down: rail at 5 V on one side, weak high on the other,
* which any real chip output overrides without a fight and without a
* contention warning.
* - LED current limiting: the LED model reads `ohms` when it works out its
* series resistance.
* - net-to-net series link: signal passes, attenuated to weak.
*
* Each side resolves the level of its own net EXCLUDING this resistor's own
* contribution to it. Without that exclusion the resistor would read back its
* own output and hold a value forever after the source went away.
*
* The 1 ns delay is not a real RC time constant, it exists so that two
* resistors facing each other cannot form a zero-delay loop and trip the
* delta-cycle guard.
*
* LIMIT OF THIS MODEL, and it is a hard one. There is NO representation of any
* voltage between 0 V and 5 V. Two resistors in series from rail to ground put
* their midpoint at weakLow + weakHigh, which resolves to LEVEL_CONTENTION and
* is read by chip inputs as INPUT_UNKNOWN not 2.5 V. For digital logic that
* is the honest answer, since a divider midpoint IS an invalid logic level. But
* anything needing real node voltages NE555 RC timing above all, and any
* analogue behaviour generally cannot be built on this. That is a nodal
* solver, a genuinely different engine, NOT an extension of the weak-pass rule.
* Budget for it as new work rather than discovering it during milestone 2.
*/
import { LEVEL_HIGH, LEVEL_WEAK_HIGH, LEVEL_LOW, LEVEL_WEAK_LOW, STRENGTH_WEAK, STRENGTH_HIGHZ, VALUE_HIGH, VALUE_LOW } from '../constants.js';
import { defineModel, WAKE_PIN } from './registry.js';
const RESISTOR_DELAY_NS = 1;
const DEFAULT_OHMS = 330;
/** Passes one side's level to the other, attenuated to weak strength. */
function pass(ctx, inst, fromPin, toPin) {
const level = ctx.levelExcludingSelf(inst, fromPin);
if (level === LEVEL_HIGH || level === LEVEL_WEAK_HIGH) {
ctx.drive(inst, toPin, STRENGTH_WEAK, VALUE_HIGH, RESISTOR_DELAY_NS);
} else if (level === LEVEL_LOW || level === LEVEL_WEAK_LOW) {
ctx.drive(inst, toPin, STRENGTH_WEAK, VALUE_LOW, RESISTOR_DELAY_NS);
} else {
// High-Z or an unresolved fight: pass nothing rather than pass a guess.
ctx.drive(inst, toPin, STRENGTH_HIGHZ, VALUE_LOW, RESISTOR_DELAY_NS);
}
}
defineModel({
type: 'resistor',
pinCount: 2,
delayNs: RESISTOR_DELAY_NS,
functionalPins: [[1, 2]],
createState(component) {
const ohms = Number(component?.props?.ohms);
return { ohms: Number.isFinite(ohms) && ohms > 0 ? ohms : DEFAULT_OHMS };
},
init(ctx, inst) {
pass(ctx, inst, 0, 1);
pass(ctx, inst, 1, 0);
},
evaluate(ctx, inst, wake) {
// A non-pin wake re-passes both directions; see the note in switches.js.
const all = wake.reason !== WAKE_PIN;
if (all || wake.pin === 0) pass(ctx, inst, 0, 1);
if (all || wake.pin === 1) pass(ctx, inst, 1, 0);
},
});
export const RESISTOR_TYPE = 'resistor';
@@ -0,0 +1,36 @@
/**
* 5 V power supply. Two pins: pin 1 (v+) onto a plus rail, pin 2 (gnd) onto the
* matching minus rail.
*
* Drives at STRENGTH_SUPPLY rather than STRENGTH_STRONG. A rail is not just a
* strong output it wins against one, and two supply drivers of opposite
* polarity meeting on one net is a short circuit rather than ordinary
* contention. Keeping supply as its own strength is what lets net-state tell
* those two faults apart.
*/
import { STRENGTH_SUPPLY, VALUE_HIGH, VALUE_LOW } from '../constants.js';
import { defineModel } from './registry.js';
const PIN_PLUS = 0;
const PIN_GND = 1;
defineModel({
type: 'powerSupply5V',
pinCount: 2,
delayNs: 0,
init(ctx, inst) {
if (inst.pins[PIN_PLUS] < 0 || inst.pins[PIN_GND] < 0) {
ctx.staticWarning('unconnectedSupply', `power supply ${inst.uid} is not attached to a rail pair`, [inst.uid]);
return;
}
ctx.driveNow(inst, PIN_PLUS, STRENGTH_SUPPLY, VALUE_HIGH);
ctx.driveNow(inst, PIN_GND, STRENGTH_SUPPLY, VALUE_LOW);
},
// A supply is not sensitive to anything: it holds its rails regardless of
// what else lands on them, which is exactly how a short circuit becomes
// visible instead of being resolved away.
evaluate() {},
});
@@ -0,0 +1,129 @@
/**
* Mechanical contacts: push button and 8-position DIP switch.
*
* Both are pure conduction elements see conduction.js for why a closed
* contact drives across rather than merging the two nets, and why it preserves
* drive strength while doing so.
*
* The 1 ns contact delay is a loop breaker, not a debounce model. Two contacts
* wired in a ring would otherwise conduct round it at zero delay forever.
*/
import { defineModel, WAKE_PIN } from './registry.js';
import { refreshBridge } from './conduction.js';
const CONTACT_DELAY_NS = 1;
/** Closes or opens the contact bridging `a` and `b`. */
function setContact(ctx, inst, a, b, closed) {
refreshBridge(ctx, inst, a, b, closed, CONTACT_DELAY_NS);
}
/* ------------------------------------------------------------------ *
* Push button: 4 pins. Pins 1+2 are permanently tied, as are 3+4
* those ties are static merges declared below and applied by the net
* builder. Pressing connects group A to group B.
* ------------------------------------------------------------------ */
const BUTTON_A = 0; // pin 1, representative of the permanently-tied A group
const BUTTON_B = 2; // pin 3, representative of the B group
defineModel({
type: 'pushButton',
pinCount: 4,
delayNs: CONTACT_DELAY_NS,
ties: [
[1, 2],
[3, 4],
],
createState(component) {
return { pressed: component?.props?.pressed === true };
},
init(ctx, inst) {
setContact(ctx, inst, BUTTON_A, BUTTON_B, inst.state.pressed);
},
evaluate(ctx, inst, wake) {
if (wake.reason === WAKE_PIN && wake.pin !== BUTTON_A && wake.pin !== BUTTON_B) return;
setContact(ctx, inst, BUTTON_A, BUTTON_B, inst.state.pressed);
},
/** `{ type:"input", uid, value }` — value is a boolean: pressed or released. */
applyInput(ctx, inst, value) {
const pressed = value === true || value?.pressed === true;
if (inst.state.pressed === pressed) return;
inst.state.pressed = pressed;
setContact(ctx, inst, BUTTON_A, BUTTON_B, pressed);
},
});
/* ------------------------------------------------------------------ *
* 8-position DIP switch: 16 pins. Switch k (1..8) bridges package pin
* k to package pin 17-k, i.e. straight across the centre gap.
* ------------------------------------------------------------------ */
const DIP_SWITCH_COUNT = 8;
/** 0-based pin indexes bridged by switch `k` (1-based). */
function dipPins(k) {
return [k - 1, 16 - k];
}
defineModel({
type: 'dipSwitch8',
pinCount: 16,
delayNs: CONTACT_DELAY_NS,
createState(component) {
const on = new Uint8Array(DIP_SWITCH_COUNT);
const source = component?.props?.on;
if (Array.isArray(source)) {
for (let i = 0; i < DIP_SWITCH_COUNT; i++) on[i] = source[i] === true ? 1 : 0;
}
return { on };
},
init(ctx, inst) {
for (let k = 1; k <= DIP_SWITCH_COUNT; k++) {
const [a, b] = dipPins(k);
setContact(ctx, inst, a, b, inst.state.on[k - 1] === 1);
}
},
evaluate(ctx, inst, wake) {
// Any non-pin wake (init, a self-scheduled timer, anything added later)
// refreshes every contact. Falling through to the pin path with
// wake.pin === -1 used to compute k = 17 and drive pin -1, which the
// old fail-open guard in drive() then aliased onto an unrelated net —
// silently wrong output rather than a crash.
if (wake.reason !== WAKE_PIN) {
for (let k = 1; k <= DIP_SWITCH_COUNT; k++) {
const [a, b] = dipPins(k);
setContact(ctx, inst, a, b, inst.state.on[k - 1] === 1);
}
return;
}
// Only the one switch straddling the woken pin can be affected.
const pin = wake.pin;
if (!(pin >= 0) || pin > 15) return;
const k = pin < DIP_SWITCH_COUNT ? pin + 1 : 16 - pin;
const [a, b] = dipPins(k);
setContact(ctx, inst, a, b, inst.state.on[k - 1] === 1);
},
/**
* `{ type:"input", uid, value:{ pin, on } }` where `pin` is the SWITCH
* number 1..8, not the 16-pin package pin number.
*/
applyInput(ctx, inst, value) {
const k = Number(value?.pin);
if (!Number.isInteger(k) || k < 1 || k > DIP_SWITCH_COUNT) return;
const on = value.on === true ? 1 : 0;
if (inst.state.on[k - 1] === on) return;
inst.state.on[k - 1] = on;
const [a, b] = dipPins(k);
setContact(ctx, inst, a, b, on === 1);
},
});
@@ -0,0 +1,158 @@
/**
* Transistors: NPN and PNP bipolars, N- and P-channel MOSFETs.
*
* All four are the same device to this engine a channel between pin 1 and
* pin 3 that the control pin on pin 2 opens and closes so they share one
* definition factory and differ only in the polarity of the turn-on test and in
* which mistakes are worth warning about. Pin order is the physical TO-92 one,
* control terminal in the middle: emitter/base/collector, source/gate/drain.
*
* Turning on takes BOTH terminals into account, never the control pin alone. An
* NPN conducts when its base is above its emitter, so an NPN whose emitter is
* already sitting on the 5 V rail stays off no matter what its base does the
* single most common reason a beginner's high-side switch does nothing.
*
* The channel terminal is read EXCLUDING this device's own contribution, and
* that is load-bearing rather than defensive. An emitter follower pulls its own
* emitter up to what it is switching; read plainly, the device would then see
* base and emitter at the same level, decide Vbe had collapsed, turn off, drop
* the emitter, turn on again, and oscillate forever at the switching delay. The
* exclusion asks the question that actually decides conduction: would current
* flow if this device were not already conducting?
*
* WHAT THIS MODEL IS NOT. There is no linear region and no gain: the device is
* saturated or cut off, nothing between. There is no Vbe and no Vce(sat), so a
* follower's output does not sit 0.7 V below its base the engine has no
* voltage between 0 and 5 V to put it at (see resistor.js). And the control pin
* is a pure high-Z input drawing no base current, which is exactly why the
* unlimited-base warning below has to exist: the model cannot punish a missing
* base resistor by melting, so it says so instead. A MOSFET's intrinsic body
* diode is not modelled either, so an off device never conducts backwards.
*
* A conducting device passes drive strength through unchanged (conduction.js),
* so a transistor wired collector-to-rail and emitter-to-ground still reports
* the short circuit it would really be.
*/
import { LEVEL_HIGH, LEVEL_WEAK_HIGH, LEVEL_LOW, LEVEL_WEAK_LOW, NO_NET, STRENGTH_SUPPLY } from '../constants.js';
import { driveStrength } from '../drive.js';
import { defineModel } from './registry.js';
import { refreshBridge } from './conduction.js';
/**
* Switching delay, ns. Slower than a contact bridging two nets because a real
* transistor's storage and rise time genuinely dominate it, and comfortably
* non-zero so a discrete inverter ring oscillates at a rate rather than
* tripping the delta-cycle guard.
*/
const SWITCHING_DELAY_NS = 10;
const PIN_CHANNEL_LOW = 0; // emitter / source
const PIN_CONTROL = 1; // base / gate
const PIN_CHANNEL_HIGH = 2; // collector / drain
function isHigh(level) {
return level === LEVEL_HIGH || level === LEVEL_WEAK_HIGH;
}
function isLow(level) {
return level === LEVEL_LOW || level === LEVEL_WEAK_LOW;
}
/**
* Whether the channel is open. A floating control pin reads as neither high nor
* low and therefore leaves the device off, which is the right answer for a
* bipolar (no base current, no conduction) and the safe one for a MOSFET, whose
* real gate would drift somewhere unpredictable hence the load-time warning.
*/
function isConducting(ctx, inst) {
const control = ctx.level(inst, PIN_CONTROL);
const channel = ctx.levelExcludingSelf(inst, PIN_CHANNEL_LOW);
return inst.model.pChannel ? isLow(control) && isHigh(channel) : isHigh(control) && isLow(channel);
}
function refreshChannel(ctx, inst) {
const conducting = isConducting(ctx, inst);
refreshBridge(ctx, inst, PIN_CHANNEL_LOW, PIN_CHANNEL_HIGH, conducting, SWITCHING_DELAY_NS);
return conducting;
}
/** A control pin nothing else touches can never switch the device. */
function warnIfControlFloating(ctx, inst) {
const net = ctx.netOf(inst, PIN_CONTROL);
if (net !== NO_NET && ctx.netListenerCount(net) > 1) return;
ctx.staticWarning(
'floatingControl',
`${inst.type} ${inst.uid}: nothing is connected to its ${inst.model.controlName}, so it can never switch on`,
[inst.uid],
);
}
/**
* Base current is what destroys a bipolar driven straight from a logic output,
* and this engine draws none so the mistake is reported the first time the
* device actually conducts with an unlimited base rather than at load, where a
* base deliberately tied to ground to hold the part off would trip it too.
* Latched per instance: a transistor switching at 1 MHz must not re-warn.
*
* Evidence of a base resistor is a resistor touching the base's net, which is
* the same dominant-resistance approximation the LED uses and inherits the same
* blind spot a resistor on that net need not be in series with the base. The
* one place that approximation is not merely imprecise but WRONG is a base
* clipped straight onto a power rail: every part on the board shares its rails,
* so a rail nearly always carries some resistor, yet nothing is in series with
* a base sitting on it. Supply drive strength is exactly what identifies that
* case, so it is tested first and overrides the resistor evidence.
*
* The result errs one way only: it can stay quiet about a real mistake, and
* never invents one.
*/
function warnIfBaseUnlimited(ctx, inst) {
if (inst.state.warnedBaseDrive) return;
const net = ctx.netOf(inst, PIN_CONTROL);
if (net === NO_NET) return;
const onSupplyRail = driveStrength(ctx.driveExcludingSelf(inst, PIN_CONTROL)) === STRENGTH_SUPPLY;
if (!onSupplyRail && ctx.dominantSeriesOhms(net) > 0) return;
inst.state.warnedBaseDrive = true;
ctx.warn(
'unlimitedBaseCurrent',
[inst.uid],
net,
`${inst.type} ${inst.uid} is switched on through a base with no series resistor; a real one would draw destructive base current`,
);
}
function defineTransistor(definition) {
defineModel({
pinCount: 3,
delayNs: SWITCHING_DELAY_NS,
// The two channel terminals must reach different nets for the device to
// switch anything; the control pin may legitimately share a net with
// either (a gate tied to its source is simply held off).
functionalPins: [[1, 3]],
createState() {
return { warnedBaseDrive: false };
},
init(ctx, inst) {
warnIfControlFloating(ctx, inst);
refreshChannel(ctx, inst);
},
evaluate(ctx, inst) {
// Any pin can change the answer: the control pin decides drive, the
// channel-low pin decides whether there is a potential to drive it
// with, and the channel-high pin changes what gets passed across.
const conducting = refreshChannel(ctx, inst);
if (conducting && inst.model.bipolar) warnIfBaseUnlimited(ctx, inst);
},
...definition,
});
}
defineTransistor({ type: 'npn', pChannel: false, bipolar: true, controlName: 'base' });
defineTransistor({ type: 'pnp', pChannel: true, bipolar: true, controlName: 'base' });
defineTransistor({ type: 'nmos', pChannel: false, bipolar: false, controlName: 'gate' });
defineTransistor({ type: 'pmos', pChannel: true, bipolar: false, controlName: 'gate' });
@@ -0,0 +1,110 @@
/**
* Net extraction: circuit document -> electrical nets.
*
* Pure. Given a circuit and the model registry, returns net ids for every
* breadboard strip and every component pin. Runs once per `load`.
*
* Three sources of connectivity are unioned:
* 1. breadboard strips and rails each is a node, supplied by
* shared/board-geometry.js `stripKey` (a column's rows a-e are one strip,
* f-j another, each rail is one strip).
* 2. wires union the two endpoints' strips.
* 3. component pins a pin adopts the net of the strip it sits in.
* 4. permanent internal ties pins a component shorts together and never
* un-shorts, e.g. the two halves of a push button's A contact. These are
* real static merges declared by the model.
*
* Switchable conduction (a pressed button, a closed DIP switch, a resistor) is
* deliberately NOT unioned. Those conduct as bidirectional *drivers* so that an
* open switch actually opens and a resistor still attenuates.
*/
import { stripKey, isValidHole } from '../shared/board-geometry.js';
import { NO_NET } from './constants.js';
import { UnionFind } from './union-find.js';
/**
* @param {object} circuit circuit document v1
* @param {import('./models/registry.js').ModelRegistry} registry
* @returns {{ netCount:number, netOfStrip:Map<string,number>,
* pinNets:Map<string,Int32Array>, warnings:Array<object> }}
*/
export function buildNets(circuit, registry) {
const warnings = [];
const uf = new UnionFind(512);
const boards = Array.isArray(circuit?.boards) ? circuit.boards : [];
const components = Array.isArray(circuit?.components) ? circuit.components : [];
const wires = Array.isArray(circuit?.wires) ? circuit.wires : [];
const boardUids = new Set(boards.map((b) => b?.uid));
const keyOf = (hole, context) => {
if (!hole || !isValidHole(hole)) {
warnings.push({ kind: 'invalidHole', detail: `${context}: hole reference is not a valid position`, hole });
return null;
}
if (!boardUids.has(hole.board)) {
warnings.push({ kind: 'invalidHole', detail: `${context}: references unknown board "${hole.board}"`, hole });
return null;
}
return stripKey(hole);
};
// 1. Strips only become nodes when something references them. An untouched
// breadboard would otherwise contribute ~700 empty nets per board.
// 2. Wires.
for (const wire of wires) {
const from = keyOf(wire?.from, `wire ${wire?.uid}`);
const to = keyOf(wire?.to, `wire ${wire?.uid}`);
if (from === null || to === null) continue;
uf.unionKeys(from, to);
}
// 3. Component pins. Interning happens before finish() so every referenced
// strip gets a net even if no wire touches it.
/** @type {Map<string, Array<string|null>>} */
const pinKeys = new Map();
for (const component of components) {
const uid = component?.uid;
if (typeof uid !== 'string') continue;
if (pinKeys.has(uid)) {
warnings.push({ kind: 'duplicateUid', detail: `duplicate component uid "${uid}"`, uids: [uid] });
continue;
}
const holes = registry.pinHoles(component);
const keys = new Array(holes.length);
for (let i = 0; i < holes.length; i++) {
const hole = holes[i]?.hole ?? null;
if (hole === null) {
keys[i] = null;
continue;
}
const key = keyOf(hole, `${component.type} ${uid} pin ${i + 1}`);
keys[i] = key;
if (key !== null) uf.intern(key);
}
pinKeys.set(uid, keys);
// 4. Permanent internal ties (e.g. a push button's two A-side pins).
for (const [a, b] of registry.internalTies(component)) {
const ka = keys[a];
const kb = keys[b];
if (ka !== null && ka !== undefined && kb !== null && kb !== undefined) uf.unionKeys(ka, kb);
}
}
const { netCount, netOfKey } = uf.finish();
/** @type {Map<string, Int32Array>} */
const pinNets = new Map();
for (const [uid, keys] of pinKeys) {
const nets = new Int32Array(keys.length);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
nets[i] = key === null ? NO_NET : netOfKey.get(key);
}
pinNets.set(uid, nets);
}
return { netCount, netOfStrip: netOfKey, pinNets, warnings };
}
@@ -0,0 +1,221 @@
/**
* Net state: the driver table and per-net signal resolution.
*
* MUTABLE HOT PATH. Everything here is typed arrays updated in place. Callers
* must go through the methods; nothing outside this file may touch the buffers.
*
* Resolution is O(1) per driver change, never a sweep. Instead of re-folding a
* net's driver list, each net carries six counters one per (strength, value)
* pair for the three real strengths and a driver change decrements one
* counter and increments another. Reading the winner is then a fixed sequence
* of integer tests. This is exactly equivalent to the pure fold in drive.js
* (`foldDrives`): the counters ARE the value-mask union, tallied. drive.spec
* cross-checks the two implementations against each other over random
* permutations.
*/
import {
LEVEL_LOW,
LEVEL_HIGH,
LEVEL_HIGHZ,
LEVEL_WEAK_LOW,
LEVEL_WEAK_HIGH,
LEVEL_CONTENTION,
STRENGTH_HIGHZ,
STRENGTH_WEAK,
STRENGTH_STRONG,
STRENGTH_SUPPLY,
} from './constants.js';
import {
FAULT_NONE,
FAULT_CONTENTION,
FAULT_SHORT_CIRCUIT,
DRIVE_HIGHZ,
MASK_LOW,
MASK_HIGH,
driveToLevel,
} from './drive.js';
const COUNTERS_PER_NET = 6;
export class NetState {
/**
* @param {number} netCount
* @param {number} driverCapacity initial driver-table size; grows by doubling
*/
constructor(netCount, driverCapacity = 64) {
this.netCount = netCount;
/** Counters: net*6 + (strength-1)*2 + value. */
this.counts = new Int32Array(netCount * COUNTERS_PER_NET);
/** Wire-format level code per net. Handed to the UI verbatim. */
this.levels = new Uint8Array(netCount).fill(LEVEL_HIGHZ);
/** Fault classification per net (FAULT_* from drive.js). */
this.faults = new Uint8Array(netCount);
this.driverCount = 0;
this.driverNet = new Int32Array(driverCapacity);
this.driverStrength = new Uint8Array(driverCapacity);
this.driverValue = new Uint8Array(driverCapacity);
}
/** Registers a new driver on `netId`, initially high-Z. Returns its id. */
addDriver(netId) {
const id = this.driverCount++;
if (id >= this.driverNet.length) this.#growDrivers();
this.driverNet[id] = netId;
this.driverStrength[id] = STRENGTH_HIGHZ;
this.driverValue[id] = 0;
return id;
}
#growDrivers() {
const capacity = this.driverNet.length * 2;
const net = new Int32Array(capacity);
net.set(this.driverNet);
const strength = new Uint8Array(capacity);
strength.set(this.driverStrength);
const value = new Uint8Array(capacity);
value.set(this.driverValue);
this.driverNet = net;
this.driverStrength = strength;
this.driverValue = value;
}
/**
* Applies a driver change.
* @returns {boolean} true when the net's resolved level actually moved
* the scheduler only propagates on a real change, so redundant drives
* cost two counter updates and stop there.
*/
setDriver(driverId, strength, value) {
const oldStrength = this.driverStrength[driverId];
const oldValue = this.driverValue[driverId];
if (oldStrength === strength && (strength === STRENGTH_HIGHZ || oldValue === value)) return false;
const netId = this.driverNet[driverId];
if (netId < 0) {
this.driverStrength[driverId] = strength;
this.driverValue[driverId] = value;
return false;
}
const base = netId * COUNTERS_PER_NET;
const counts = this.counts;
if (oldStrength !== STRENGTH_HIGHZ) counts[base + (oldStrength - 1) * 2 + oldValue]--;
if (strength !== STRENGTH_HIGHZ) counts[base + (strength - 1) * 2 + value]++;
this.driverStrength[driverId] = strength;
this.driverValue[driverId] = value;
return this.refresh(netId);
}
/** Recomputes one net's level and fault. Returns true if the level changed. */
refresh(netId) {
const base = netId * COUNTERS_PER_NET;
const counts = this.counts;
const supplyLow = counts[base + 4] !== 0;
const supplyHigh = counts[base + 5] !== 0;
const strongLow = counts[base + 2] !== 0;
const strongHigh = counts[base + 3] !== 0;
let level;
let fault = FAULT_NONE;
if (supplyLow || supplyHigh) {
if (supplyLow && supplyHigh) {
// Rail+ tied to rail-.
level = LEVEL_CONTENTION;
fault = FAULT_SHORT_CIRCUIT;
} else if (supplyHigh) {
level = LEVEL_HIGH;
// A chip output pulling against the rail is still a fight.
if (strongLow) fault = FAULT_CONTENTION;
} else {
level = LEVEL_LOW;
if (strongHigh) fault = FAULT_CONTENTION;
}
} else if (strongLow || strongHigh) {
if (strongLow && strongHigh) {
level = LEVEL_CONTENTION;
fault = FAULT_CONTENTION;
} else {
level = strongHigh ? LEVEL_HIGH : LEVEL_LOW;
}
} else {
const weakLow = counts[base] !== 0;
const weakHigh = counts[base + 1] !== 0;
if (weakLow && weakHigh) {
// Pull-up versus pull-down. Indeterminate as a logic level, but a
// resistor divider is not a fault, so no warning is raised.
level = LEVEL_CONTENTION;
} else if (weakHigh) {
level = LEVEL_WEAK_HIGH;
} else if (weakLow) {
level = LEVEL_WEAK_LOW;
} else {
level = LEVEL_HIGHZ;
}
}
this.faults[netId] = fault;
if (this.levels[netId] === level) return false;
this.levels[netId] = level;
return true;
}
levelOf(netId) {
return netId < 0 ? LEVEL_HIGHZ : this.levels[netId];
}
faultOf(netId) {
return netId < 0 ? FAULT_NONE : this.faults[netId];
}
/**
* Resolves a net as it would be WITHOUT one particular driver's
* contribution, returning a packed drive (see drive.js) so the caller keeps
* the STRENGTH, not just the level.
*
* Bidirectional elements need this. A closed switch contact must pass what
* the other side sees, and if it counted its own output it would latch onto
* its own value forever. Strength has to survive the trip too: a button
* bridging the two rails has to pass SUPPLY strength through, or the far
* rail sees a merely-strong driver and the short reads as ordinary
* contention instead of a short circuit.
*
* Temporarily decrements the driver's own counter rather than copying the
* net hot path, and the mutation is restored before returning.
*/
driveExcluding(netId, driverId) {
if (netId < 0) return DRIVE_HIGHZ;
const strength = this.driverStrength[driverId];
const base = netId * COUNTERS_PER_NET;
if (strength === STRENGTH_HIGHZ) return this.#peek(base);
const counts = this.counts;
const slot = base + (strength - 1) * 2 + this.driverValue[driverId];
counts[slot]--;
const drive = this.#peek(base);
counts[slot]++;
return drive;
}
levelExcluding(netId, driverId) {
return driveToLevel(this.driveExcluding(netId, driverId));
}
/** Folded drive on a net, as a packed (strength, valueMask) pair. */
#peek(base) {
const counts = this.counts;
let mask = (counts[base + 4] !== 0 ? MASK_LOW : 0) | (counts[base + 5] !== 0 ? MASK_HIGH : 0);
if (mask !== 0) return STRENGTH_SUPPLY * 4 + mask;
mask = (counts[base + 2] !== 0 ? MASK_LOW : 0) | (counts[base + 3] !== 0 ? MASK_HIGH : 0);
if (mask !== 0) return STRENGTH_STRONG * 4 + mask;
mask = (counts[base] !== 0 ? MASK_LOW : 0) | (counts[base + 1] !== 0 ? MASK_HIGH : 0);
if (mask !== 0) return STRENGTH_WEAK * 4 + mask;
return DRIVE_HIGHZ;
}
}
export { FAULT_NONE, FAULT_CONTENTION, FAULT_SHORT_CIRCUIT };
@@ -0,0 +1,661 @@
/**
* Event-driven simulation core.
*
* MUTABLE HOT PATH. Owns the event queue, the driver table and the device
* instances. Models talk to it through the context methods on this class; they
* never reach into its buffers.
*
* Scheduling model
* ----------------
* Events carry (timeNs, seq) so equal-time events fire in insertion order and
* the whole run is reproducible whatever order components appear in the
* document. Applying a driver change resolves its net in O(1) and, only if the
* resolved LEVEL actually moved, wakes the devices listening on that net. Waking
* a device is a direct call, not another queue entry the queue holds delayed
* *effects*, not intentions, which halves event volume.
*
* Delay is INERTIAL, not transport. If a gate's input moves again before its
* pending output event fires, the pending event is cancelled rather than
* queued behind it, so a pulse narrower than the gate's tPD is swallowed the
* way a real gate swallows it. Cancellation is O(1): every driver carries a
* generation counter, scheduling bumps it, and an event whose generation no
* longer matches is dropped when popped. The heap is never scanned.
*
* Two independent limits, which must not be confused
* --------------------------------------------------
* 1. The delta-cycle guard counts events resolved at ONE UNCHANGED simTime.
* Exceeding it means a combinational loop with no delay, so time can never
* advance a genuine fault, reported as an "oscillation" warning, and the
* sim pauses.
* 2. The per-batch budget in `runEvents` is cooperative yielding, nothing more.
* It exists so the worker returns to its message loop and stays responsive.
* A 74HC04 ring oscillator hits it constantly and that is entirely healthy:
* simTime advances on every event, so the delta guard never sees it.
*/
import { LEVEL_HIGHZ, MAX_EVENTS_PER_INSTANT, NO_NET, STRENGTH_HIGHZ } from './constants.js';
import { DRIVE_HIGHZ, FAULT_NONE, FAULT_CONTENTION, FAULT_SHORT_CIRCUIT } from './drive.js';
import { EventQueue, EVENT_DRIVE, EVENT_TIMER } from './event-queue.js';
import { NetState } from './net-state.js';
import { buildNets } from './net-builder.js';
import { registry as defaultRegistry, WAKE_INIT, WAKE_PIN, WAKE_TIMER } from './models/index.js';
import { RESISTOR_TYPE } from './models/resistor.js';
import { LED_TYPE } from './models/led.js';
/** Packs (strength, value) into the small code carried by a queued event. */
function packCode(strength, value) {
return strength === STRENGTH_HIGHZ ? 0 : strength * 2 + (value ? 1 : 0);
}
const MAX_OSCILLATION_CULPRITS = 12;
/** Cap on self-short reports per document, so a pathological circuit cannot flood. */
const MAX_SELF_SHORT_WARNINGS = 20;
/** Ring buffer of the most recent drivers applied at one instant, for diagnostics. */
const DELTA_RING_SIZE = 32;
const DELTA_RING_MASK = DELTA_RING_SIZE - 1;
export class Simulation {
/**
* @param {object} circuit circuit document v1
* @param {object} [options]
* @param {object} [options.registry] model registry (tests may substitute)
* @param {Map<string,number>} [options.delayOverridesNs] per-type delay override,
* used by tests to force zero-delay loops
*/
constructor(circuit, options = {}) {
this.registry = options.registry ?? defaultRegistry;
this.delayOverridesNs = options.delayOverridesNs ?? null;
const extraction = buildNets(circuit, this.registry);
this.netCount = extraction.netCount;
this.netOfStrip = extraction.netOfStrip;
this.warnings = extraction.warnings.slice();
this.queue = new EventQueue(1024);
this.timeNs = 0;
this.halted = false;
this.haltReason = null;
this.#buildDevices(circuit, extraction.pinNets);
this.#buildListeners();
this.#buildSeriesResistance();
this.#checkSelfShorts(circuit);
// Reused across every model wake so evaluation allocates nothing.
this.wake = { reason: WAKE_INIT, pin: -1, prevLevel: 0, level: 0, timerId: 0 };
this.netFaultReported = new Uint8Array(this.netCount);
this.shortedNetCount = 0;
// Depth of nested model evaluation. Guards `driveNow` and documents the
// invariant that `wake` and model scratch buffers are only safe because
// evaluation never re-enters itself.
this.evaluating = 0;
this.deltaTimeNs = -1;
this.deltaCount = 0;
this.deltaRing = new Int32Array(DELTA_RING_SIZE).fill(-1);
this.deltaRingCount = 0;
this.oscillationReported = false;
this.#initDevices();
}
/* ---------------------------------------------------------------- *
* Construction
* ---------------------------------------------------------------- */
#buildDevices(circuit, pinNets) {
const components = Array.isArray(circuit?.components) ? circuit.components : [];
this.devices = [];
this.deviceByUid = new Map();
this.ledOrder = [];
let driverCapacityHint = 0;
for (const component of components) {
const model = this.registry.get(component?.type);
if (!model) {
if (component?.type) {
this.warnings.push({
kind: 'unknownComponent',
detail: `no simulation model for component type "${component.type}"`,
uids: [component.uid],
});
}
continue;
}
const nets = pinNets.get(component.uid);
if (!nets) continue;
const index = this.devices.length;
const pinCount = Math.max(model.pinCount, nets.length);
const pins = new Int32Array(pinCount).fill(NO_NET);
pins.set(nets.subarray(0, Math.min(nets.length, pinCount)));
const inst = {
index,
uid: component.uid,
type: component.type,
model,
props: component.props ?? {},
state: model.createState ? model.createState(component) : null,
pins,
drivers: new Int32Array(pinCount).fill(-1),
delayNs: this.#delayFor(model),
ledOrdinal: -1,
};
this.devices.push(inst);
this.deviceByUid.set(component.uid, inst);
if (component.type === LED_TYPE) {
inst.ledOrdinal = this.ledOrder.length;
this.ledOrder.push(component.uid);
}
driverCapacityHint += pinCount;
}
// Every connected pin owns a driver from the start, whether or not the
// model ever drives it. That is what makes pin direction a runtime
// property: a model can assert or release any pin at any moment without
// the engine having been told in advance which pins are outputs.
this.nets = new NetState(this.netCount, Math.max(64, driverCapacityHint));
this.driverOwner = new Int32Array(Math.max(64, driverCapacityHint)).fill(-1);
for (const inst of this.devices) {
for (let pin = 0; pin < inst.pins.length; pin++) {
if (inst.pins[pin] === NO_NET) continue;
const driverId = this.nets.addDriver(inst.pins[pin]);
inst.drivers[pin] = driverId;
if (driverId >= this.driverOwner.length) this.#growDriverOwners(driverId + 1);
this.driverOwner[driverId] = inst.index;
}
}
// Float64, matching event-queue's `seq` for the same reason: these are
// unbounded monotonic counters, and an int32 that silently wraps turns a
// stale event into a live one. Practically unreachable, but the cost of
// consistency here is zero.
this.driverGen = new Float64Array(this.nets.driverCount + 1);
this.driverPending = new Int32Array(this.nets.driverCount + 1).fill(-1);
this.ledCurrentsMilliamps = new Float32Array(this.ledOrder.length);
}
#growDriverOwners(minimum) {
let capacity = this.driverOwner.length * 2;
while (capacity < minimum) capacity *= 2;
const owner = new Int32Array(capacity).fill(-1);
owner.set(this.driverOwner);
this.driverOwner = owner;
}
#delayFor(model) {
if (this.delayOverridesNs) {
const override = this.delayOverridesNs.get(model.type);
if (override !== undefined) return override;
}
return model.delayNs;
}
/**
* Net -> listening (device, pin) pairs, in compressed sparse row form. A flat
* pair of typed arrays plus an offset table keeps wake dispatch to one
* contiguous scan instead of chasing per-net sub-arrays.
*/
#buildListeners() {
const counts = new Int32Array(this.netCount + 1);
let total = 0;
for (const inst of this.devices) {
for (let pin = 0; pin < inst.pins.length; pin++) {
const net = inst.pins[pin];
if (net === NO_NET) continue;
counts[net + 1]++;
total++;
}
}
for (let i = 0; i < this.netCount; i++) counts[i + 1] += counts[i];
this.listenerStart = counts;
this.listenerDevice = new Int32Array(total);
this.listenerPin = new Uint8Array(total);
const cursor = counts.slice(0, this.netCount);
for (const inst of this.devices) {
for (let pin = 0; pin < inst.pins.length; pin++) {
const net = inst.pins[pin];
if (net === NO_NET) continue;
const slot = cursor[net]++;
this.listenerDevice[slot] = inst.index;
this.listenerPin[slot] = pin;
}
}
}
/** Smallest resistor touching each net, for LED series-resistance lookup. */
#buildSeriesResistance() {
/** @type {Map<number, number>} */
this.minOhmsByNet = new Map();
for (const inst of this.devices) {
if (inst.type !== RESISTOR_TYPE) continue;
const ohms = inst.state?.ohms;
if (!Number.isFinite(ohms)) continue;
for (let pin = 0; pin < 2; pin++) {
const net = inst.pins[pin];
if (net === NO_NET) continue;
const existing = this.minOhmsByNet.get(net);
if (existing === undefined || ohms < existing) this.minOhmsByNet.set(net, ohms);
}
}
}
/**
* Load-time only: reports components whose own terminals share a net, which
* makes the component electrically inert.
*
* This is worth a warning precisely because it is INVISIBLE. An LED wired
* across a single terminal strip reports 0 mA and 0 mA is also what a
* normally-off LED reports, so neither the user nor the UI can tell the
* difference between "not lit right now" and "can never light". Same for a
* resistor bridged across one strip (silently contributing nothing) and a
* switch whose two contacts are already common (pressing it does nothing).
*
* Two sources, because neither alone is sufficient:
* - GEOMETRY, via shared's componentSelfShorts: both legs placed in one
* strip. Already excludes pins bonded by design, so a push button's 1-2
* and 3-4 never appear.
* - NETS: the same pin pairs resolved through union-find, which also
* catches a short made by a WIRE rather than by placement invisible to
* geometry, and just as dead. Design ties are subtracted here too.
*
* Never emitted at runtime, and capped per document.
*/
#checkSelfShorts(circuit) {
const components = Array.isArray(circuit?.components) ? circuit.components : [];
let emitted = 0;
for (const component of components) {
if (emitted >= MAX_SELF_SHORT_WARNINGS) break;
const inst = this.deviceByUid.get(component?.uid);
if (!inst) continue;
const seen = new Set();
const offending = [];
const record = (a, b, cause) => {
const key = a < b ? `${a}:${b}` : `${b}:${a}`;
if (seen.has(key)) return;
seen.add(key);
offending.push({ a, b, cause });
};
for (const [a, b] of this.registry.selfShorts(component)) record(a, b, 'placed in the same terminal strip');
for (const [a, b] of this.registry.functionalPairs(component)) {
const netA = inst.pins[a];
const netB = inst.pins[b];
if (netA >= 0 && netA === netB) record(a, b, 'connected to the same net');
}
if (offending.length === 0) continue;
emitted++;
const described = offending.map((o) => `pins ${o.a + 1} and ${o.b + 1} are ${o.cause}`).join('; ');
this.warnings.push({
kind: 'selfShorted',
uids: [inst.uid],
netId: inst.pins[offending[0].a],
detail: `${inst.type} ${inst.uid} is shorted across itself and can have no effect: ${described}`,
});
}
}
#initDevices() {
this.wake.reason = WAKE_INIT;
this.wake.pin = -1;
for (const inst of this.devices) {
if (inst.model.init) inst.model.init(this, inst);
}
for (const inst of this.devices) {
this.wake.reason = WAKE_INIT;
this.wake.pin = -1;
this.#evaluate(inst, this.wake);
}
}
/* ---------------------------------------------------------------- *
* Model-facing context
* ---------------------------------------------------------------- */
/*
* PIN ACCESSORS FAIL CLOSED note the `>= 0` tests rather than `< 0`.
* Reading a typed array past its end yields `undefined`, and `undefined < 0`
* is FALSE, so a `< 0` guard lets an out-of-range pin through. It then
* indexes a real driver (commonly driver 0, which is live on a live net) and
* silently drives an unrelated part of the circuit. `>= 0` is false for
* `undefined`, so the bad pin is rejected instead of aliased.
*/
level(inst, pin) {
const net = inst.pins[pin];
return net >= 0 ? this.nets.levels[net] : LEVEL_HIGHZ;
}
netOf(inst, pin) {
const net = inst.pins[pin];
return net >= 0 ? net : NO_NET;
}
levelExcludingSelf(inst, pin) {
const net = inst.pins[pin];
const driverId = inst.drivers[pin];
if (!(net >= 0) || !(driverId >= 0)) return LEVEL_HIGHZ;
return this.nets.levelExcluding(net, driverId);
}
driveExcludingSelf(inst, pin) {
const net = inst.pins[pin];
const driverId = inst.drivers[pin];
if (!(net >= 0) || !(driverId >= 0)) return DRIVE_HIGHZ;
return this.nets.driveExcluding(net, driverId);
}
/**
* Schedules a driver change `delayNs` from now, with inertial semantics: a
* still-pending change on the same driver is cancelled, and a request that
* matches what is already pending (or already applied) costs nothing.
*/
drive(inst, pin, strength, value, delayNs) {
const driverId = inst.drivers[pin];
if (!(driverId >= 0)) return;
const code = packCode(strength, value);
const currentCode = packCode(this.nets.driverStrength[driverId], this.nets.driverValue[driverId]);
const pending = this.driverPending[driverId];
const effective = pending >= 0 ? pending : currentCode;
if (code === effective) return;
this.driverGen[driverId]++;
if (code === currentCode) {
// The input moved back before the pending edge fired: glitch swallowed.
this.driverPending[driverId] = -1;
return;
}
this.driverPending[driverId] = code;
const delay = delayNs === undefined ? inst.delayNs : delayNs;
this.queue.push(this.timeNs + delay, EVENT_DRIVE, driverId, code, this.driverGen[driverId]);
}
/**
* Applies a driver change immediately, bypassing the queue.
*
* SAFE TO CALL FROM `evaluate`. Applying immediately from inside a model's
* evaluate would re-enter #applyDriver -> #propagate, which clobbers the
* shared `wake` record mid-loop and hands every remaining listener the wrong
* pin a silent wrong answer, not a crash. Rather than document that as a
* rule for future model authors to remember, the hazard is removed: during
* evaluation this degrades to a zero-delay queued change, which lands in the
* same sim instant and goes through the normal, non-reentrant path.
*/
driveNow(inst, pin, strength, value) {
const driverId = inst.drivers[pin];
if (!(driverId >= 0)) return;
if (this.evaluating > 0) {
this.drive(inst, pin, strength, value, 0);
return;
}
this.driverGen[driverId]++;
this.driverPending[driverId] = -1;
this.#applyDriver(driverId, strength, value);
}
/** Wakes this device again at `timeNs + deltaNs` with no input change. */
scheduleSelf(inst, deltaNs, timerId = 0) {
if (!(inst?.index >= 0) || !Number.isFinite(deltaNs) || deltaNs < 0) return;
this.queue.push(this.timeNs + deltaNs, EVENT_TIMER, inst.index, timerId, 0);
}
warn(kind, uids, netId, detail) {
this.warnings.push({ kind, uids, netId, detail });
}
/** A load-time issue: reported once in the `loaded` message, never repeated. */
staticWarning(kind, detail, uids) {
this.warnings.push({ kind, detail, uids });
}
setLedCurrent(inst, amps) {
if (inst.ledOrdinal >= 0) this.ledCurrentsMilliamps[inst.ledOrdinal] = amps * 1000;
}
/**
* How many component pins sit on `netId`. A pin whose net has no other
* occupant is genuinely floating nothing can ever drive it which is how
* models tell "unwired input" apart from "input not driven yet at load".
*/
netListenerCount(netId) {
if (netId === NO_NET) return 0;
return this.listenerStart[netId + 1] - this.listenerStart[netId];
}
/** Smallest resistance touching `netId`, or 0 when nothing limits it. */
dominantSeriesOhms(netId) {
if (netId === NO_NET) return 0;
return this.minOhmsByNet.get(netId) ?? 0;
}
/* ---------------------------------------------------------------- *
* Running
* ---------------------------------------------------------------- */
/**
* Processes up to `budget` events.
* @returns {number} events actually processed. Fewer than the budget means
* the circuit settled or the sim halted; neither is an error.
*/
runEvents(budget) {
const queue = this.queue;
let processed = 0;
while (processed < budget && !this.halted && queue.size > 0) {
// The delta check reads the NEXT event's time before popping, so a
// guard trip leaves that event in the queue. Losing it would make the
// circuit look settled on the next run, hiding the fault instead of
// reporting it again.
const nextTime = queue.peekTime();
if (nextTime === this.deltaTimeNs) {
if (++this.deltaCount > MAX_EVENTS_PER_INSTANT) {
this.#reportOscillation();
break;
}
} else {
this.deltaTimeNs = nextTime;
this.deltaCount = 0;
this.deltaRingCount = 0;
}
queue.pop();
this.timeNs = queue.outTime;
if (queue.outKind === EVENT_DRIVE) {
const driverId = queue.outTarget;
if (queue.outGen === this.driverGen[driverId]) {
this.driverPending[driverId] = -1;
this.deltaRing[this.deltaRingCount++ & DELTA_RING_MASK] = driverId;
this.#applyDriver(driverId, queue.outArg >> 1, queue.outArg & 1);
}
} else {
const inst = this.devices[queue.outTarget];
this.wake.reason = WAKE_TIMER;
this.wake.pin = -1;
this.wake.timerId = queue.outArg;
this.#evaluate(inst, this.wake);
}
processed++;
}
return processed;
}
/** True when nothing more will happen without outside input. */
get isSettled() {
return this.queue.size === 0;
}
/**
* Clears a halt and the delta counter so the user can run or step again.
* A short circuit is the exception: resuming into a still-shorted supply
* would let the sim run in a state that would have destroyed real hardware,
* so the halt stands until the offending connection is removed. The warning
* is not re-emitted the fault is already latched per net.
*
* @returns {boolean} whether the simulation is now runnable.
*/
resume() {
if (this.shortedNetCount > 0) {
this.halted = true;
this.haltReason = 'shortCircuit';
return false;
}
this.halted = false;
this.haltReason = null;
this.deltaTimeNs = -1;
this.deltaCount = 0;
this.deltaRingCount = 0;
return true;
}
#applyDriver(driverId, strength, value) {
const nets = this.nets;
const netId = nets.driverNet[driverId];
const prevLevel = nets.levelOf(netId);
if (!nets.setDriver(driverId, strength, value)) {
// The level held, but the fault classification may still have moved
// (a second driver of the same polarity arriving, say).
this.#checkFault(netId);
return;
}
this.#checkFault(netId);
this.#propagate(netId, prevLevel, nets.levelOf(netId));
}
/**
* The single place a model's `evaluate` is invoked. Everything that makes
* evaluation allocation-free the reused `wake` record, the shared input
* scratch buffer in logic-ic.js depends on evaluation never re-entering
* itself, so the depth counter lives here rather than at each call site.
*/
#evaluate(inst, wake) {
this.evaluating++;
inst.model.evaluate(this, inst, wake);
this.evaluating--;
}
#propagate(netId, prevLevel, newLevel) {
const start = this.listenerStart[netId];
const end = this.listenerStart[netId + 1];
const wake = this.wake;
for (let i = start; i < end; i++) {
const inst = this.devices[this.listenerDevice[i]];
wake.reason = WAKE_PIN;
wake.pin = this.listenerPin[i];
wake.prevLevel = prevLevel;
wake.level = newLevel;
this.#evaluate(inst, wake);
}
}
#checkFault(netId) {
const fault = this.nets.faultOf(netId);
const previous = this.netFaultReported[netId];
if (fault === previous) return;
if (previous === FAULT_SHORT_CIRCUIT) this.shortedNetCount--;
if (fault === FAULT_SHORT_CIRCUIT) this.shortedNetCount++;
this.netFaultReported[netId] = fault;
if (fault === FAULT_NONE) return;
const uids = this.#driversOnNet(netId);
if (fault === FAULT_SHORT_CIRCUIT) {
this.halted = true;
this.haltReason = 'shortCircuit';
this.warn('shortCircuit', uids, netId, `net ${netId} ties the 5V rail directly to ground`);
} else if (fault === FAULT_CONTENTION) {
this.warn('contention', uids, netId, `net ${netId} is driven high and low at the same time`);
}
}
/** uids of every device with a pin on `netId`, for warning payloads. */
#driversOnNet(netId) {
const uids = [];
const start = this.listenerStart[netId];
const end = this.listenerStart[netId + 1];
for (let i = start; i < end; i++) {
const uid = this.devices[this.listenerDevice[i]].uid;
if (!uids.includes(uid)) uids.push(uid);
}
return uids;
}
#reportOscillation() {
this.halted = true;
this.haltReason = 'oscillation';
if (this.oscillationReported) return;
this.oscillationReported = true;
// Name the participants from the drivers most recently applied at this
// same timestamp, plus whatever is still queued for it. Between them
// those are exactly the elements going round the loop.
const uids = [];
const netIds = [];
const note = (driverId) => {
if (driverId < 0 || uids.length >= MAX_OSCILLATION_CULPRITS) return;
const owner = this.driverOwner[driverId];
if (owner >= 0) {
const uid = this.devices[owner].uid;
if (!uids.includes(uid)) uids.push(uid);
}
const netId = this.nets.driverNet[driverId];
if (netId >= 0 && !netIds.includes(netId)) netIds.push(netId);
};
for (let i = 0; i < DELTA_RING_SIZE; i++) note(this.deltaRing[i]);
const queue = this.queue;
for (let i = 0; i < queue.length; i++) {
if (queue.time[i] === this.deltaTimeNs && queue.kind[i] === EVENT_DRIVE) note(queue.target[i]);
}
this.warnings.push({
kind: 'oscillation',
uids,
netId: netIds.length > 0 ? netIds[0] : -1,
detail:
`circuit will not settle: over ${MAX_EVENTS_PER_INSTANT} events resolved at ${this.deltaTimeNs} ns ` +
`without simulated time advancing. Zero-delay feedback loop through net(s) ${netIds.join(', ')}` +
(uids.length > 0 ? ` and component(s) ${uids.join(', ')}` : '') +
'. Simulation paused.',
});
}
/* ---------------------------------------------------------------- *
* Outside world
* ---------------------------------------------------------------- */
/** Handles a `{ type:"input", uid, value }` message. */
applyInput(uid, value) {
const inst = this.deviceByUid.get(uid);
if (!inst || !inst.model.applyInput) return false;
inst.model.applyInput(this, inst, value);
return true;
}
/**
* Net map handed to the UI once at load, so it can colour holes and wires
* straight from `frame.netLevels` without re-deriving connectivity.
* Unconnected pins are -1.
*/
netIndex() {
const strips = {};
for (const [key, netId] of this.netOfStrip) strips[key] = netId;
const components = {};
for (const inst of this.devices) components[inst.uid] = Array.from(inst.pins);
return { strips, components, ledOrder: this.ledOrder.slice() };
}
/** Drains accumulated warnings. Callers own the returned array. */
drainWarnings() {
if (this.warnings.length === 0) return [];
const drained = this.warnings;
this.warnings = [];
return drained;
}
/** Runs to quiescence, bounded. Used right after load. */
settle(maxEvents = 200000) {
return this.runEvents(maxEvents);
}
}
export { EVENT_DRIVE, EVENT_TIMER };
@@ -0,0 +1,90 @@
/**
* Union-find over string keys, backed by typed arrays.
*
* Used once per `load` to collapse breadboard strips, rails, wires and
* component pins into nets. Keys arrive as strings (strip keys from
* shared/board-geometry.js) and are interned to dense integer ids so the
* parent/rank arrays can be Int32Array.
*/
export class UnionFind {
constructor(expectedKeys = 256) {
/** @type {Map<string, number>} */
this.ids = new Map();
this.parent = new Int32Array(expectedKeys);
this.rank = new Uint8Array(expectedKeys);
this.size = 0;
}
/** Interns a key, returning its dense id. Creates the key if it is new. */
intern(key) {
const existing = this.ids.get(key);
if (existing !== undefined) return existing;
const id = this.size++;
if (id >= this.parent.length) this.#grow();
this.parent[id] = id;
this.rank[id] = 0;
this.ids.set(key, id);
return id;
}
#grow() {
const parent = new Int32Array(this.parent.length * 2);
parent.set(this.parent);
const rank = new Uint8Array(parent.length);
rank.set(this.rank);
this.parent = parent;
this.rank = rank;
}
find(id) {
const parent = this.parent;
let root = id;
while (parent[root] !== root) root = parent[root];
// Path compression, iterative so deep chains cannot blow the stack.
while (parent[id] !== root) {
const next = parent[id];
parent[id] = root;
id = next;
}
return root;
}
union(a, b) {
let ra = this.find(a);
let rb = this.find(b);
if (ra === rb) return ra;
const rank = this.rank;
if (rank[ra] < rank[rb]) {
const t = ra;
ra = rb;
rb = t;
}
this.parent[rb] = ra;
if (rank[ra] === rank[rb]) rank[ra]++;
return ra;
}
unionKeys(keyA, keyB) {
return this.union(this.intern(keyA), this.intern(keyB));
}
/**
* Assigns each root a dense net id in ascending root order, then returns
* `{ netCount, netOfKey }` where netOfKey maps every interned key to its net.
* Deterministic: net ids depend only on intern order, which depends only on
* the circuit document, never on hash iteration of the roots.
*/
finish() {
const netOfRoot = new Int32Array(this.size).fill(-1);
let netCount = 0;
for (let id = 0; id < this.size; id++) {
const root = this.find(id);
if (netOfRoot[root] === -1) netOfRoot[root] = netCount++;
}
/** @type {Map<string, number>} */
const netOfKey = new Map();
for (const [key, id] of this.ids) netOfKey.set(key, netOfRoot[this.find(id)]);
return { netCount, netOfKey };
}
}
@@ -0,0 +1,428 @@
/**
* Module Web Worker hosting the simulation. Speaks the spec's protocol.
*
* Main -> worker:
* { type:"load", circuit }
* { type:"run" } | { type:"pause" } | { type:"step", count }
* { type:"setSpeed", eventsPerSecond }
* { type:"input", uid, value }
* { type:"reset" }
*
* Worker -> main:
* { type:"loaded", nets, warnings, netIndex }
* { type:"frame", netLevels, ledStates, simTimeNs, running, settled, halted }
* { type:"warning", kind, uids, netId, detail }
* { type:"error", context, message }
*
* This file's whole job is to keep the main thread healthy. The simulation can
* legitimately produce millions of events and hundreds of thousands of warnings
* per second; none of that may reach the UI at that rate. Three independent
* limiters enforce that, and they are independent ON PURPOSE each bounds a
* different resource, and collapsing them would leave a hole:
*
* 1. TIME per tick (`TICK_BUDGET_MS`) bounds how long the worker can be deaf
* to incoming messages. A count-based budget cannot do this job: per-event
* cost is circuit-dependent, so any fixed event count is simultaneously too
* slow on a heavy circuit and too coarse on a light one. The budget is
* checked every `EVENTS_PER_TIME_CHECK` events, so `pause` is always heard
* within a few milliseconds regardless of what the circuit is doing.
* 2. FRAMES per second (`FRAME_HZ`) bounds render pressure. EVERY frame goes
* through `requestFrame`, including the ones triggered by pause, step,
* input and load a UI sending an input per mousemove would otherwise
* punch straight through the throttle. Suppressed frames are not dropped:
* a trailing-edge timer flushes the final state, so the UI can never be
* left showing something stale.
* 3. WARNINGS per tick and per key (`MAX_WARNINGS_PER_TICK`,
* `WARNING_COOLDOWN_MS`) bound message volume. See `flushWarnings`.
*
* `eventsPerSecond` remains the user-facing SPEED control and is applied on top
* of the time budget; whichever binds first wins.
*
* Frame buffers are allocated per frame rather than double-buffered. They are
* TRANSFERRED, which neuters them on this side, so a reused buffer would come
* back detached and throw; and at 60 Hz a few kilobytes is far below the noise
* floor of anything else the worker does.
*/
import { Simulation } from './simulation.js';
const FRAME_HZ = 60;
const FRAME_INTERVAL_MS = 1000 / FRAME_HZ;
const DEFAULT_EVENTS_PER_SECOND = 1_000_000;
/** Wall-clock ceiling for one tick, so `pause` is never more than this away. */
const TICK_BUDGET_MS = 8;
/** How often the time budget is consulted. Small enough to be responsive. */
const EVENTS_PER_TIME_CHECK = 4096;
/** At most this many `warning` messages leave the worker per tick. */
const MAX_WARNINGS_PER_TICK = 20;
/** The same warning identity is not re-sent more often than this. */
const WARNING_COOLDOWN_MS = 1000;
/** Cap on remembered warning identities, so the dedupe map cannot grow forever. */
const MAX_WARNING_KEYS = 500;
/** @type {Simulation|null} */
let sim = null;
let circuit = null;
let running = false;
let eventsPerSecond = DEFAULT_EVENTS_PER_SECOND;
let timer = null;
let lastFrameAt = -Infinity;
let frameTimer = null;
/** key -> timestamp last posted, for warning dedupe across ticks. */
const warningLastPosted = new Map();
let suppressedWarnings = 0;
let lastSuppressionReportAt = -Infinity;
function now() {
return typeof performance !== 'undefined' ? performance.now() : Date.now();
}
function post(message, transfers) {
self.postMessage(message, transfers ?? []);
}
/* ------------------------------------------------------------------ *
* Warnings
* ------------------------------------------------------------------ */
/**
* Identity of a warning for dedupe purposes. Deliberately excludes `detail`,
* which carries changing numbers (a fluctuating current, a timestamp) and would
* make every repeat look unique which is precisely how an unbounded flood
* gets through a naive dedupe.
*/
function warningKey(warning) {
return `${warning.kind}|${warning.netId ?? -1}|${(warning.uids ?? []).join(',')}`;
}
/**
* Posts warnings under a hard budget.
*
* A warning is a NOTIFICATION, not a log entry. The engine may generate them at
* event rate an LED driven at 25 mA by a running oscillator crosses the 20 mA
* threshold on every cycle, which is hundreds of thousands of warnings per
* second, and contention on a toggling net does the same. Posting one message
* each would wedge the main thread, which is the single failure this worker
* exists to prevent.
*
* So: identical warnings are collapsed by identity with a cooldown, at most
* MAX_WARNINGS_PER_TICK escape per tick, and anything held back is reported as
* a single coalesced count rather than silently dropped. Bounding it HERE
* rather than in the models covers every warning kind at once, including kinds
* added later.
*/
function flushWarnings() {
if (!sim) return;
const drained = sim.drainWarnings();
if (drained.length === 0 && suppressedWarnings === 0) return;
const at = now();
if (warningLastPosted.size > MAX_WARNING_KEYS) warningLastPosted.clear();
let emitted = 0;
for (const warning of drained) {
const key = warningKey(warning);
const last = warningLastPosted.get(key);
if (last !== undefined && at - last < WARNING_COOLDOWN_MS) {
suppressedWarnings++;
continue;
}
if (emitted >= MAX_WARNINGS_PER_TICK) {
suppressedWarnings++;
continue;
}
warningLastPosted.set(key, at);
emitted++;
post({
type: 'warning',
kind: warning.kind,
uids: warning.uids ?? [],
netId: warning.netId ?? -1,
detail: warning.detail ?? '',
});
}
if (suppressedWarnings > 0 && at - lastSuppressionReportAt >= WARNING_COOLDOWN_MS) {
const count = suppressedWarnings;
suppressedWarnings = 0;
lastSuppressionReportAt = at;
post({
type: 'warning',
kind: 'warningsSuppressed',
uids: [],
netId: -1,
detail: `${count} further warning${count === 1 ? '' : 's'} suppressed — the circuit is repeating a fault every cycle`,
});
}
}
function resetWarningThrottle() {
warningLastPosted.clear();
suppressedWarnings = 0;
lastSuppressionReportAt = -Infinity;
}
/**
* Turns an unexpected throw into something the UI can show.
*
* Without this, any throw escapes as an unhandled worker error: the protocol
* has no error path, so main never gets `loaded`, never gets a `frame`, and
* never learns why the UI simply waits forever on a dead worker. A silent
* hang is the worst possible failure mode, so every entry point funnels here.
*
* The failure is reported as the top-level `{ type:"error", context, message }`
* rather than as a `warning`. The runtime `warning.kind` enum is CLOSED, so an
* engine fault is not expressible in it and an engine fault is categorically
* different anyway: a warning describes the user's circuit, an error describes
* the simulator failing to run at all.
*/
function reportEngineError(context, error) {
running = false;
stopTimer();
const message = error?.message ?? String(error);
try {
post({ type: 'error', context, message });
// `{type:"error"}` alone still leaves a `load` caller blocked on the
// `loaded` it is waiting for, so answer that too. Inside
// `loaded.warnings` the kind set is open, so `loadFailed` is legal there.
if (context === 'load' && !sim) {
post({
type: 'loaded',
nets: 0,
warnings: [{ kind: 'loadFailed', uids: [], netId: -1, detail: `circuit failed to load: ${message}` }],
netIndex: { strips: {}, components: {}, ledOrder: [] },
});
}
} catch {
// postMessage itself failed; nothing further is possible.
}
}
/* ------------------------------------------------------------------ *
* Frames
* ------------------------------------------------------------------ */
function postFrame() {
if (!sim) return;
clearFrameTimer();
// Fresh buffers: these are transferred and neutered on the way out.
const netLevels = new Uint8Array(sim.nets.levels);
const ledStates = new Float32Array(sim.ledCurrentsMilliamps);
lastFrameAt = now();
post(
{
type: 'frame',
netLevels,
ledStates,
simTimeNs: sim.timeNs,
running,
settled: sim.isSettled,
halted: sim.halted,
},
[netLevels.buffer, ledStates.buffer],
);
}
function clearFrameTimer() {
if (frameTimer !== null) {
clearTimeout(frameTimer);
frameTimer = null;
}
}
/**
* The ONLY way a frame leaves this worker. Emits immediately when the throttle
* allows, otherwise arms a trailing-edge timer so the final state still arrives
* a suppressed frame is delayed, never dropped, or the UI would be left
* rendering a stale circuit.
*/
function requestFrame(force = false) {
if (!sim) return;
const since = now() - lastFrameAt;
if (force || since >= FRAME_INTERVAL_MS) {
postFrame();
return;
}
if (frameTimer === null) frameTimer = setTimeout(postFrame, Math.max(0, FRAME_INTERVAL_MS - since));
}
/* ------------------------------------------------------------------ *
* Running
* ------------------------------------------------------------------ */
function stopTimer() {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
}
function scheduleTick() {
stopTimer();
if (!running) return;
timer = setTimeout(tick, FRAME_INTERVAL_MS);
}
/** Events allowed this tick by the user's speed setting. */
function speedBudget() {
return Math.max(1, Math.round(eventsPerSecond / FRAME_HZ));
}
/**
* Runs up to `eventBudget` events, but never for longer than TICK_BUDGET_MS.
* Time is checked every EVENTS_PER_TIME_CHECK events rather than every event,
* so the clock read is amortised to nothing on the hot path.
*/
function runBudgeted(eventBudget) {
if (!sim) return 0;
const deadline = now() + TICK_BUDGET_MS;
let processed = 0;
while (processed < eventBudget && !sim.halted) {
const chunk = Math.min(EVENTS_PER_TIME_CHECK, eventBudget - processed);
const did = sim.runEvents(chunk);
processed += did;
if (did < chunk) break; // settled, or halted mid-chunk
if (now() >= deadline) break;
}
return processed;
}
function tick() {
timer = null;
if (!sim || !running) return;
try {
runTick();
} catch (error) {
reportEngineError('run', error);
}
}
function runTick() {
runBudgeted(speedBudget());
flushWarnings();
if (sim.halted || sim.isSettled) {
// A settled circuit needs no more ticks until the user touches
// something; `input` restarts the loop. This is not a stall.
running = false;
requestFrame();
return;
}
requestFrame();
scheduleTick();
}
function load(nextCircuit) {
stopTimer();
clearFrameTimer();
resetWarningThrottle();
running = false;
lastFrameAt = -Infinity;
// Dropped BEFORE constructing, so a throw part-way through leaves no
// simulation rather than the previous one — otherwise a failed load leaves
// the worker quietly serving frames from a circuit the user has replaced.
sim = null;
// Replaces the whole simulation object. Nothing from the previous load is
// carried over, so repeated loads cannot accumulate nets, drivers or events.
const next = new Simulation(nextCircuit);
next.settle();
sim = next;
// Only remembered once the load succeeded, so `reset` cannot replay a
// circuit that could not be built.
circuit = nextCircuit;
const warnings = sim.drainWarnings();
post({
type: 'loaded',
nets: sim.netCount,
warnings,
netIndex: sim.netIndex(),
});
requestFrame(true);
}
self.onmessage = (event) => {
const message = event.data;
if (!message || typeof message.type !== 'string') return;
try {
handleMessage(message);
} catch (error) {
reportEngineError(message.type, error);
}
};
function handleMessage(message) {
switch (message.type) {
case 'load':
load(message.circuit);
break;
case 'run':
if (!sim) break;
// resume() refuses while the supply is still shorted; honour that
// rather than pretending to run.
if (sim.halted && !sim.resume()) {
requestFrame();
break;
}
running = true;
scheduleTick();
break;
case 'pause':
running = false;
stopTimer();
requestFrame();
break;
case 'step': {
if (!sim) break;
// Stepping clears a halt so the user can single-step into a
// non-converging loop and look at it, but the guard is still armed:
// the step itself terminates rather than spinning.
if (sim.halted) sim.resume();
running = false;
stopTimer();
const count = Number.isFinite(message.count) && message.count > 0 ? Math.floor(message.count) : 1;
sim.runEvents(count);
flushWarnings();
requestFrame();
break;
}
case 'setSpeed': {
const rate = Number(message.eventsPerSecond);
if (Number.isFinite(rate) && rate > 0) eventsPerSecond = rate;
break;
}
case 'input': {
if (!sim) break;
sim.applyInput(message.uid, message.value);
if (sim.halted) sim.resume();
if (!running) {
// Settle the consequences of the interaction even while paused,
// otherwise a button press appears to do nothing.
runBudgeted(speedBudget());
if (!sim.isSettled && !sim.halted) {
running = true;
scheduleTick();
}
}
flushWarnings();
requestFrame();
break;
}
case 'reset':
if (circuit) load(circuit);
break;
default:
break;
}
}
@@ -0,0 +1,353 @@
// Breadboard geometry: hole coordinates and strip connectivity.
//
// PURE MODULE. No DOM, no globals, no dependencies. It is imported by the editor on
// the main thread AND by the simulation engine inside a module Worker, so it must be
// loadable in bare node too. Do not add side effects at import time.
//
// Coordinate spaces used here:
// board space - px relative to a board's own top-left corner
// world space - board space + the board's {x, y}; pan/zoom is applied on top of
// this by the renderer, never in this file
// This file never sees screen/CSS pixels.
//
// Physical model: full-size 830-point breadboard.
// 63 columns; rows a-e and f-j are separate 5-hole terminal strips per column;
// a center channel between rows e and f; four power rails of 50 holes each, and
// each rail is ONE continuous net end-to-end (no mid-board split) for v1.
/** World px between adjacent holes (one 0.1" pitch). */
export const PITCH = 20;
/** Number of main-grid columns, numbered 1..BOARD_COLUMNS. */
export const BOARD_COLUMNS = 63;
/** Holes per power rail, numbered 1..RAIL_HOLES. */
export const RAIL_HOLES = 50;
/** Main-grid row letters, ordered top to bottom. */
export const MAIN_ROWS = Object.freeze(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']);
/** Power rail names, ordered top to bottom as they appear on the board. */
export const RAIL_NAMES = Object.freeze(['topPlus', 'topMinus', 'bottomMinus', 'bottomPlus']);
/** Rows above the center channel (one electrical strip per column). */
export const UPPER_ROWS = Object.freeze(['a', 'b', 'c', 'd', 'e']);
/** Rows below the center channel (one electrical strip per column). */
export const LOWER_ROWS = Object.freeze(['f', 'g', 'h', 'i', 'j']);
// --- Board space layout, expressed in pitch units from the board's top-left ---
const MARGIN_COLS = 1; // blank margin left of column 1
const ROW_Y_PITCH = Object.freeze(Object.assign(Object.create(null), {
a: 4.5, b: 5.5, c: 6.5, d: 7.5, e: 8.5,
f: 11.5, g: 12.5, h: 13.5, i: 14.5, j: 15.5
}));
const RAIL_Y_PITCH = Object.freeze(Object.assign(Object.create(null), {
topPlus: 1, topMinus: 2, bottomMinus: 18, bottomPlus: 19
}));
// Rail holes sit in 10 groups of 5 with a one-pitch gap between groups, and the whole
// run is inset from the main grid - matching a real board. Derived independently of
// the main columns on purpose: rail hole 7 is NOT under column 7.
const RAIL_GROUP_SIZE = 5;
const RAIL_GROUP_STRIDE = 6; // 5 holes + 1 blank
const RAIL_X0_PITCH = 3;
/** Board width in world px. */
export const BOARD_WIDTH = (BOARD_COLUMNS + 2 * MARGIN_COLS) * PITCH;
/** Board height in world px. */
export const BOARD_HEIGHT = 20 * PITCH;
/** Y of the top of the center channel, in board space. */
export const CHANNEL_TOP = (ROW_Y_PITCH.e + 1) * PITCH;
/** Y of the bottom of the center channel, in board space. */
export const CHANNEL_BOTTOM = (ROW_Y_PITCH.f - 1) * PITCH;
/** How close (world px) a point must be to a hole to count as over it. */
export const HOLE_HIT_RADIUS = PITCH * 0.5;
const MAIN_ROW_INDEX = Object.freeze(
MAIN_ROWS.reduce((acc, row, i) => { acc[row] = i; return acc; }, Object.create(null))
);
function isPositiveInt(value, max) {
return Number.isInteger(value) && value >= 1 && value <= max;
}
/**
* Structural validity of a hole reference. Does NOT check that the referenced board
* exists in a circuit - that is circuit-schema's job.
* @param {*} hole
* @returns {boolean}
*/
export function isValidHole(hole) {
if (!hole || typeof hole !== 'object') return false;
if (typeof hole.board !== 'string' || hole.board.length === 0) return false;
if (hole.kind === 'main') {
return isPositiveInt(hole.col, BOARD_COLUMNS)
&& typeof hole.row === 'string'
&& Object.prototype.hasOwnProperty.call(MAIN_ROW_INDEX, hole.row);
}
if (hole.kind === 'rail') {
return RAIL_NAMES.indexOf(hole.rail) !== -1 && isPositiveInt(hole.index, RAIL_HOLES);
}
return false;
}
/**
* Canonical identity of a single hole.
*
* Assumes board uids conform to circuit-schema's UID_PATTERN, which excludes the '|'
* delimiter. normalizeCircuit enforces that charset, so a uid can never split a key
* into the wrong number of segments.
* @returns {string|null} e.g. "b1|m|12|e" or "b1|h|topPlus|7"
*/
export function holeKey(hole) {
if (!isValidHole(hole)) return null;
return hole.kind === 'main'
? `${hole.board}|m|${hole.col}|${hole.row}`
: `${hole.board}|h|${hole.rail}|${hole.index}`;
}
/**
* Canonical identity of the electrically-common strip a hole belongs to. Two holes
* are directly connected by the board itself iff their stripKeys are equal.
* @returns {string|null} e.g. "b1|s|12|ae", "b1|s|12|fj", "b1|r|topPlus"
*/
export function stripKey(hole) {
if (!isValidHole(hole)) return null;
if (hole.kind === 'rail') return `${hole.board}|r|${hole.rail}`;
const half = MAIN_ROW_INDEX[hole.row] <= MAIN_ROW_INDEX.e ? 'ae' : 'fj';
return `${hole.board}|s|${hole.col}|${half}`;
}
/**
* Inverse of holeKey.
* @returns {object|null} a hole reference, or null if the key is malformed
*/
export function parseHoleKey(key) {
if (typeof key !== 'string') return null;
const parts = key.split('|');
if (parts.length !== 4) return null;
const [board, tag, a, b] = parts;
let hole = null;
if (tag === 'm') {
hole = { board, kind: 'main', col: Number(a), row: b };
} else if (tag === 'h') {
hole = { board, kind: 'rail', rail: a, index: Number(b) };
}
return isValidHole(hole) ? hole : null;
}
/**
* Every hole on the same strip as the given hole, including the hole itself.
* @returns {object[]} 5 holes for a main strip, RAIL_HOLES for a rail, [] if invalid
*/
export function holesInStrip(hole) {
if (!isValidHole(hole)) return [];
if (hole.kind === 'rail') {
const holes = [];
for (let i = 1; i <= RAIL_HOLES; i++) {
holes.push({ board: hole.board, kind: 'rail', rail: hole.rail, index: i });
}
return holes;
}
const rows = MAIN_ROW_INDEX[hole.row] <= MAIN_ROW_INDEX.e ? UPPER_ROWS : LOWER_ROWS;
return rows.map(row => ({ board: hole.board, kind: 'main', col: hole.col, row }));
}
/** True when both holes are valid and share one electrical strip. */
export function sameStrip(a, b) {
const ka = stripKey(a);
return ka !== null && ka === stripKey(b);
}
/** True when both holes are valid and are the same physical hole. */
export function sameHole(a, b) {
const ka = holeKey(a);
return ka !== null && ka === holeKey(b);
}
/**
* Step from a hole in a direction. Main-grid up/down move one row and may cross the
* center channel (e <-> f). Rail up/down is meaningless and returns null.
* @param {object} hole
* @param {'left'|'right'|'up'|'down'} dir
* @param {number} [n=1] number of steps
* @returns {object|null} the hole n steps away, or null if it falls off the board
*/
export function offsetHole(hole, dir, n = 1) {
if (!isValidHole(hole) || !Number.isInteger(n)) return null;
if (hole.kind === 'rail') {
if (dir !== 'left' && dir !== 'right') return null;
const index = hole.index + (dir === 'right' ? n : -n);
const moved = { board: hole.board, kind: 'rail', rail: hole.rail, index };
return isValidHole(moved) ? moved : null;
}
if (dir === 'left' || dir === 'right') {
const col = hole.col + (dir === 'right' ? n : -n);
const moved = { board: hole.board, kind: 'main', col, row: hole.row };
return isValidHole(moved) ? moved : null;
}
if (dir === 'up' || dir === 'down') {
const rowIndex = MAIN_ROW_INDEX[hole.row] + (dir === 'down' ? n : -n);
if (rowIndex < 0 || rowIndex >= MAIN_ROWS.length) return null;
return { board: hole.board, kind: 'main', col: hole.col, row: MAIN_ROWS[rowIndex] };
}
return null;
}
/**
* Build a main-grid hole, or null when the column is off the board. Convenience for
* pin-mapping code that computes columns arithmetically.
*/
export function mainHole(board, col, row) {
const hole = { board, kind: 'main', col, row };
return isValidHole(hole) ? hole : null;
}
/** Build a rail hole, or null when out of range. */
export function railHole(board, rail, index) {
const hole = { board, kind: 'rail', rail, index };
return isValidHole(hole) ? hole : null;
}
// --- Positions ---
/** X of a main-grid column, in board space. */
export function columnX(col) {
return (MARGIN_COLS + col - 1) * PITCH;
}
/** Y of a main-grid row, in board space. */
export function rowY(row) {
const p = ROW_Y_PITCH[row];
return p === undefined ? null : p * PITCH;
}
/** X of a rail hole, in board space. Deliberately not aligned to columnX. */
export function railHoleX(index) {
const group = Math.floor((index - 1) / RAIL_GROUP_SIZE);
const within = (index - 1) % RAIL_GROUP_SIZE;
return (RAIL_X0_PITCH + group * RAIL_GROUP_STRIDE + within) * PITCH;
}
/** Y of a rail, in board space. */
export function railY(rail) {
const p = RAIL_Y_PITCH[rail];
return p === undefined ? null : p * PITCH;
}
/**
* Position of a hole in board space.
* @returns {{x:number,y:number}|null}
*/
export function holeLocalPos(hole) {
if (!isValidHole(hole)) return null;
return hole.kind === 'main'
? { x: columnX(hole.col), y: rowY(hole.row) }
: { x: railHoleX(hole.index), y: railY(hole.rail) };
}
/**
* Position of a hole in world space.
* @param {object} hole
* @param {Map<string,{x:number,y:number}>|Record<string,{x:number,y:number}>} boards
* @returns {{x:number,y:number}|null}
*/
export function holeWorldPos(hole, boards) {
const local = holeLocalPos(hole);
if (local === null) return null;
const board = boards instanceof Map ? boards.get(hole.board) : (boards ? boards[hole.board] : null);
if (!board) return null;
return { x: board.x + local.x, y: board.y + local.y };
}
/** World-space bounding box of a board. */
export function boardBounds(board) {
return {
x: board.x,
y: board.y,
w: BOARD_WIDTH,
h: BOARD_HEIGHT,
right: board.x + BOARD_WIDTH,
bottom: board.y + BOARD_HEIGHT
};
}
/** True when a world-space point lies within a board's outline. */
export function pointInBoard(board, worldX, worldY) {
return worldX >= board.x && worldX <= board.x + BOARD_WIDTH
&& worldY >= board.y && worldY <= board.y + BOARD_HEIGHT;
}
/**
* Nearest hole on one board to a world-space point.
* Snaps to the grid rather than scanning every hole.
* @returns {{hole:object, dist:number}|null}
*/
export function nearestHoleOnBoard(board, worldX, worldY, maxDist = HOLE_HIT_RADIUS) {
const lx = worldX - board.x;
const ly = worldY - board.y;
let best = null;
const consider = (hole) => {
const pos = holeLocalPos(hole);
if (pos === null) return;
const dx = pos.x - lx;
const dy = pos.y - ly;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist <= maxDist && (best === null || dist < best.dist)) best = { hole, dist };
};
const col = Math.round(lx / PITCH) - MARGIN_COLS + 1;
for (const row of MAIN_ROWS) {
consider(mainHole(board.uid, col, row));
}
// Rails: invert railHoleX to get the candidate index without scanning all 50.
const railUnit = lx / PITCH - RAIL_X0_PITCH;
const group = Math.floor(railUnit / RAIL_GROUP_STRIDE);
for (let g = group - 1; g <= group + 1; g++) {
if (g < 0 || g >= RAIL_HOLES / RAIL_GROUP_SIZE) continue;
const within = Math.round(railUnit - g * RAIL_GROUP_STRIDE);
if (within < 0 || within >= RAIL_GROUP_SIZE) continue;
const index = g * RAIL_GROUP_SIZE + within + 1;
for (const rail of RAIL_NAMES) {
consider(railHole(board.uid, rail, index));
}
}
return best;
}
/**
* Nearest hole to a world-space point across all boards.
* @param {number} worldX
* @param {number} worldY
* @param {Array<{uid:string,x:number,y:number}>} boards
* @param {number} [maxDist]
* @returns {object|null} the hole reference, or null when nothing is close enough
*/
export function holeAtWorldPoint(worldX, worldY, boards, maxDist = HOLE_HIT_RADIUS) {
let best = null;
for (const board of boards) {
const hit = nearestHoleOnBoard(board, worldX, worldY, maxDist);
if (hit !== null && (best === null || hit.dist < best.dist)) best = hit;
}
return best === null ? null : best.hole;
}
/**
* The board a world-space point falls on, or null.
* @param {Array<{uid:string,x:number,y:number}>} boards
*/
export function boardAtWorldPoint(worldX, worldY, boards) {
for (let i = boards.length - 1; i >= 0; i--) {
if (pointInBoard(boards[i], worldX, worldY)) return boards[i];
}
return null;
}
@@ -0,0 +1,729 @@
// Circuit document schema v1: create, normalize and validate.
//
// PURE MODULE. No DOM, no globals. The structural rules here are mirrored by the
// server-side C# validator, so keep them explicit and keep the error strings terse
// and stable.
//
// Two entry points with deliberately different temperaments:
// normalizeCircuit - LENIENT. Coerces anything into a usable document so a user is
// never locked out of their own project. It REPORTS everything
// it could not keep; it never discards silently.
// validateCircuit - STRICT. Mirrors the server. This is the gate.
// INVARIANT: the output of normalizeCircuit always passes validateCircuit. Several
// call sites depend on it, and the shared test suite asserts it by fuzzing.
import {
isValidHole,
holeKey,
BOARD_WIDTH,
BOARD_HEIGHT
} from './board-geometry.js';
import {
getComponentDef,
isKnownType,
validateProps,
defaultPropsFor,
dipOrientForRow
} from './component-registry.js';
import { componentPinHoles, isFullyPlaced } from './component-pins.js';
/** Schema version emitted by this build. */
export const CIRCUIT_VERSION = 1;
/** Maximum length of any uid in the document. */
export const MAX_UID_LENGTH = 40;
// Count caps pinned by team-lead amendment A11, identical in the C# validator. These
// are PRODUCT limits only: passing them says nothing about the byte cap below, because
// a document at every count cap with 40-character uids still measures over 3 MB. The
// two limits are independent and both are enforced.
export const MAX_BOARDS = 50;
export const MAX_COMPONENTS = 3000;
export const MAX_WIRES = 10000;
/**
* Hard byte ceiling on the serialized document (UTF-8), enforced by the server
* independently of the count caps. The save path MUST preflight against this with a
* real byte count - see circuitByteSize - so an oversized document is refused with an
* explanation rather than surfacing as a bare 400.
*/
export const MAX_CIRCUIT_BYTES = 2 * 1024 * 1024;
/** Uid character set accepted by the server-side validator. */
export const UID_PATTERN = new RegExp(`^[A-Za-z0-9_.:-]{1,${MAX_UID_LENGTH}}$`);
const UID_INVALID_CHARS = /[^A-Za-z0-9_.:-]/g;
/** Horizontal gap left between boards when a new one is appended. */
export const BOARD_STACK_GAP = 60;
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
/** Largest absolute board coordinate the server accepts. */
export const MAX_BOARD_COORD = 1000000;
/** Default wire colors offered in the editor, in picker order. */
export const WIRE_COLORS = Object.freeze([
'#d13438', '#2b6cb0', '#2f9e44', '#e8a33d', '#7048e8', '#f2f2f2', '#1a1a1a', '#e07a9c'
]);
/** Color used for a wire with no explicit color. */
export const DEFAULT_WIRE_COLOR = WIRE_COLORS[1];
/**
* The orient value a component should carry.
*
* For DIP-style packages the real rotation is carried by which side of the channel
* the anchor sits on, so `orient` is derived from the anchor row rather than stored
* independently - that keeps the two from ever contradicting each other. Everything
* else uses `orient` directly.
*/
export function orientFor(def, anchor, requested) {
if (!def.orientable) return undefined;
if (def.dipStyle) {
return anchor && anchor.kind === 'main' ? dipOrientForRow(anchor.row) : def.defaultOrient;
}
return def.orientValues.indexOf(requested) !== -1 ? requested : def.defaultOrient;
}
// --- Uid allocation ---
/**
* Coerce a string into the server's uid charset, or null when nothing usable remains.
* Renaming beats dropping: a bad uid must never cost the user a component.
*/
export function sanitizeUid(value) {
if (typeof value !== 'string') return null;
const cleaned = value.replace(UID_INVALID_CHARS, '-').slice(0, MAX_UID_LENGTH);
return cleaned.length > 0 ? cleaned : null;
}
/**
* Allocates document-unique uids in amortized constant time.
*
* nextUid() rebuilds the used-set on every call, which is fine for placing one
* component but quadratic over a whole document. Any loop that creates more than a
* handful of items must use this instead.
* @param {string[]} [existing] uids already taken
*/
export function createUidAllocator(existing = []) {
const used = new Set(existing);
const counters = new Map();
return {
has(uid) { return used.has(uid); },
/** Claim `preferred` if it is free and well-formed, otherwise mint one. */
claim(preferred, prefix) {
const clean = sanitizeUid(preferred);
if (clean !== null && !used.has(clean)) {
used.add(clean);
return clean;
}
return this.next(prefix);
},
/** Mint the next free uid with the given prefix. The counter never rewinds. */
next(prefix) {
let n = counters.get(prefix) || 1;
while (used.has(prefix + n)) n++;
counters.set(prefix, n + 1);
const uid = prefix + n;
used.add(uid);
return uid;
}
};
}
// --- Construction ---
/**
* A new, empty circuit with a single board at the origin.
* @returns {object}
*/
export function createCircuit() {
return {
version: CIRCUIT_VERSION,
boards: [{ uid: 'b1', x: 0, y: 0 }],
components: [],
wires: []
};
}
/** Structured clone of a circuit, safe to mutate. */
export function cloneCircuit(circuit) {
return JSON.parse(JSON.stringify(circuit));
}
/**
* Next unused uid with the given prefix, e.g. nextUid(c, 'c') -> "c7".
*
* A pure function of the document: same content, same answer, every time. O(document),
* which is fine for placing a single item.
*
* DO NOT call this in a loop. Filling a document one nextUid at a time is quadratic.
* Bulk paths use createUidAllocator, and a long-lived editor should keep one allocator
* alongside its circuit - uid allocation is session state, not a property of the
* document, and modelling it as the latter does not work.
*/
export function nextUid(circuit, prefix) {
const used = new Set(allUids(circuit));
let n = 1;
while (used.has(prefix + n)) n++;
return prefix + n;
}
/** Every uid in the document. */
export function allUids(circuit) {
const uids = [];
for (const list of [circuit.boards, circuit.components, circuit.wires]) {
if (Array.isArray(list)) {
for (const item of list) {
if (item && typeof item.uid === 'string') uids.push(item.uid);
}
}
}
return uids;
}
/** Board with the given uid, or null. */
export function findBoard(circuit, uid) {
return circuit.boards.find(b => b.uid === uid) || null;
}
/** Component with the given uid, or null. */
export function findComponent(circuit, uid) {
return circuit.components.find(c => c.uid === uid) || null;
}
/** Wire with the given uid, or null. */
export function findWire(circuit, uid) {
return circuit.wires.find(w => w.uid === uid) || null;
}
/**
* Boards keyed by uid - the shape board-geometry's holeWorldPos expects.
* @returns {Map<string, object>}
*/
export function boardsByUid(circuit) {
return new Map(circuit.boards.map(b => [b.uid, b]));
}
/**
* Append a board below the existing ones and return it. Mutates `circuit`.
* @returns {object|null} the new board, or null when MAX_BOARDS is reached
*/
export function addBoard(circuit) {
if (circuit.boards.length >= MAX_BOARDS) return null;
const bottom = circuit.boards.reduce(
(max, b) => Math.max(max, b.y + BOARD_HEIGHT), -BOARD_STACK_GAP
);
const board = { uid: nextUid(circuit, 'b'), x: 0, y: bottom + BOARD_STACK_GAP };
circuit.boards.push(board);
return board;
}
/**
* Remove a board and everything anchored to or wired into it. Mutates `circuit`.
* Refuses to remove the last remaining board.
* @returns {boolean} whether the board was removed
*/
export function removeBoard(circuit, uid) {
if (circuit.boards.length <= 1) return false;
const index = circuit.boards.findIndex(b => b.uid === uid);
if (index === -1) return false;
circuit.boards.splice(index, 1);
circuit.wires = circuit.wires.filter(w => !wireTouchesBoard(w, uid));
circuit.components = circuit.components.filter(c => !componentTouchesBoard(c, uid));
return true;
}
/** True when either end of a wire sits on the given board. */
export function wireTouchesBoard(wire, boardUid) {
return (wire.from && wire.from.board === boardUid) || (wire.to && wire.to.board === boardUid);
}
/** True when any part of a component sits on the given board. */
export function componentTouchesBoard(component, boardUid) {
if (component.anchor && component.anchor.board === boardUid) return true;
if (component.props && component.props.board === boardUid) return true;
if (component.props && component.props.to && component.props.to.board === boardUid) return true;
return componentPinHoles(component).some(p => p.hole !== null && p.hole.board === boardUid);
}
/**
* A component of the given type with registry defaults applied. Does not add it to
* the circuit.
*
* Anchorless types ignore `anchor` and take their placement from `extraProps`
* instead - a powerSupply5V needs `{ board, side }` there or it will not validate.
* @param {object} circuit used only to allocate a uid
* @param {string} type
* @param {object|null} anchor hole reference, ignored for anchorless types
* @param {object} [extraProps] merged over the type defaults
*/
export function createComponent(circuit, type, anchor, extraProps) {
const def = getComponentDef(type);
if (def === null) return null;
const component = {
uid: nextUid(circuit, 'c'),
type,
props: Object.assign(defaultPropsFor(type), extraProps || {})
};
// The server-side validator whitelists keys strictly, so an anchorless type omits
// `anchor` entirely rather than carrying an explicit null.
if (!def.anchorless) component.anchor = anchor;
if (def.orientable) component.orient = orientFor(def, anchor, def.defaultOrient);
return component;
}
/** A wire between two holes, with a uid allocated from the circuit. */
export function createWire(circuit, from, to, color) {
return {
uid: nextUid(circuit, 'w'),
from,
to,
color: HEX_COLOR.test(color) ? color : DEFAULT_WIRE_COLOR
};
}
// --- Normalization ---
function normalizeBoardCoord(value, fallback) {
if (!Number.isFinite(value)) return fallback;
return Math.max(-MAX_BOARD_COORD, Math.min(MAX_BOARD_COORD, value));
}
/**
* Coerce anything - a parsed JSONB blob, `{}`, null, a JSON string - into a usable
* circuit, reporting everything that could not be kept.
*
* Nothing is discarded silently. Entries with a broken uid are RENAMED rather than
* dropped, and a board rename is propagated to every hole reference that pointed at
* it, because dropping a board would cascade into deleting all of its components and
* wires.
*
* @param {*} raw
* @returns {{circuit: object, problems: Array<{kind:string, index:number, uid:string|null, reason:string}>}}
*/
export function normalizeCircuitWithReport(raw) {
const problems = [];
const note = (kind, index, uid, reason) => problems.push({ kind, index, uid, reason });
let source = raw;
if (typeof source === 'string') {
try {
source = JSON.parse(source);
} catch {
note('circuit', -1, null, 'the saved document was not valid JSON');
source = null;
}
}
if (!source || typeof source !== 'object' || Array.isArray(source)) {
return { circuit: createCircuit(), problems };
}
// --- boards, with a rename map so hole references follow their board ---
const rawBoards = Array.isArray(source.boards) ? source.boards : [];
const allocator = createUidAllocator();
const renames = new Map();
const boards = [];
for (let i = 0; i < rawBoards.length && boards.length < MAX_BOARDS; i++) {
const rawBoard = rawBoards[i];
if (!rawBoard || typeof rawBoard !== 'object') {
note('board', i, null, 'entry was not an object');
continue;
}
const original = typeof rawBoard.uid === 'string' ? rawBoard.uid : null;
// The FIRST board claiming a name keeps it, so references stay unambiguous.
const uid = allocator.claim(original, 'b');
if (original === null) {
note('board', i, null, `a board had no id and was given "${uid}"`);
} else if (original !== uid) {
// Reported even when the name was already remapped: this is the duplicate
// case, where every reference to it attaches to the FIRST board and this
// one is left empty. Silent data movement is what the report exists for.
note('board', i, original, renames.has(original)
? `a second board also called "${original}" was renamed to "${uid}"; anything referring to "${original}" stayed with the first one`
: `board "${original}" was renamed to "${uid}" (unsupported characters)`);
}
if (original !== null && !renames.has(original)) renames.set(original, uid);
boards.push({
uid,
x: normalizeBoardCoord(Number(rawBoard.x), 0),
y: normalizeBoardCoord(Number(rawBoard.y), i * (BOARD_HEIGHT + BOARD_STACK_GAP))
});
}
if (rawBoards.length > MAX_BOARDS) {
note('board', -1, null, `only the first ${MAX_BOARDS} boards were kept`);
}
if (boards.length === 0) boards.push({ uid: allocator.claim('b1', 'b'), x: 0, y: 0 });
const boardUids = new Set(boards.map(b => b.uid));
/** Resolve a raw hole reference, following any board rename. */
const normalizeHole = (rawHole) => {
if (!rawHole || typeof rawHole !== 'object') return null;
const board = renames.has(rawHole.board) ? renames.get(rawHole.board) : rawHole.board;
const hole = rawHole.kind === 'rail'
? { board, kind: 'rail', rail: rawHole.rail, index: Number(rawHole.index) }
: { board, kind: 'main', col: Number(rawHole.col), row: rawHole.row };
if (!isValidHole(hole) || !boardUids.has(hole.board)) return null;
return hole;
};
// --- components ---
const rawComponents = Array.isArray(source.components) ? source.components : [];
const components = [];
const suppliedRailPairs = new Set();
for (let i = 0; i < rawComponents.length; i++) {
if (components.length >= MAX_COMPONENTS) {
note('component', -1, null, `only the first ${MAX_COMPONENTS} components were kept`);
break;
}
const rawComponent = rawComponents[i];
const label = rawComponent && typeof rawComponent.uid === 'string' ? rawComponent.uid : null;
if (!rawComponent || typeof rawComponent !== 'object') {
note('component', i, null, 'entry was not an object');
continue;
}
if (!isKnownType(rawComponent.type)) {
note('component', i, label, `unknown component type "${String(rawComponent.type)}"`);
continue;
}
const def = getComponentDef(rawComponent.type);
const component = {
uid: allocator.claim(label, 'c'),
type: rawComponent.type,
props: defaultPropsFor(rawComponent.type)
};
if (label !== null && label !== component.uid) {
note('component', i, label, `renamed to "${component.uid}" (duplicate or unsupported characters)`);
}
if (!def.anchorless) {
const anchor = normalizeHole(rawComponent.anchor);
if (anchor === null) {
note('component', i, label, `${def.label} was not on a board that still exists`);
continue;
}
component.anchor = anchor;
}
const rawProps = rawComponent.props && typeof rawComponent.props === 'object'
? rawComponent.props : {};
for (const [key, spec] of Object.entries(def.propSpecs)) {
const value = rawProps[key];
if (spec.kind === 'enum' && spec.values.indexOf(value) !== -1) {
component.props[key] = value;
} else if (spec.kind === 'number' && Number.isFinite(Number(value))) {
component.props[key] = Math.min(spec.max, Math.max(spec.min, Number(value)));
} else if (spec.kind === 'boolArray') {
const rawList = Array.isArray(value) ? value : [];
component.props[key] = Array.from({ length: spec.length }, (_, slot) => rawList[slot] === true);
}
}
if (rawComponent.type === 'resistor') {
const to = normalizeHole(rawProps.to);
if (to === null) {
note('component', i, label, 'resistor had no valid second terminal');
continue;
}
component.props.to = to;
}
if (rawComponent.type === 'powerSupply5V') {
const board = renames.has(rawProps.board) ? renames.get(rawProps.board) : rawProps.board;
if (typeof board !== 'string' || !boardUids.has(board)) {
note('component', i, label, '5V supply was not on a board that still exists');
continue;
}
component.props.board = board;
// Two supplies on one rail pair would be spurious contention, and validate
// rejects it - so normalize must not emit it either.
const pair = `${board}|${component.props.side}`;
if (suppliedRailPairs.has(pair)) {
note('component', i, label,
`a second 5V supply on the ${component.props.side} rails of board "${board}" was removed`);
continue;
}
suppliedRailPairs.add(pair);
}
if (def.orientable) component.orient = orientFor(def, component.anchor, rawComponent.orient);
// Enforced here so normalize's output always passes validate: a component with
// a pin hanging off the end of the board is rejected by the server.
if (!isFullyPlaced(component)) {
note('component', i, label, `${def.label} did not fit on the board`);
continue;
}
components.push(component);
}
// --- wires ---
const rawWires = Array.isArray(source.wires) ? source.wires : [];
const wires = [];
for (let i = 0; i < rawWires.length; i++) {
if (wires.length >= MAX_WIRES) {
note('wire', -1, null, `only the first ${MAX_WIRES} wires were kept`);
break;
}
const rawWire = rawWires[i];
const label = rawWire && typeof rawWire.uid === 'string' ? rawWire.uid : null;
if (!rawWire || typeof rawWire !== 'object') {
note('wire', i, null, 'entry was not an object');
continue;
}
const from = normalizeHole(rawWire.from);
const to = normalizeHole(rawWire.to);
if (from === null || to === null) {
note('wire', i, label, 'wire did not connect two holes that still exist');
continue;
}
if (holeKey(from) === holeKey(to)) {
note('wire', i, label, 'wire had both ends in the same hole');
continue;
}
const uid = allocator.claim(label, 'w');
if (label !== null && label !== uid) {
note('wire', i, label, `renamed to "${uid}" (duplicate or unsupported characters)`);
}
wires.push({
uid,
from,
to,
color: typeof rawWire.color === 'string' && HEX_COLOR.test(rawWire.color)
? rawWire.color : DEFAULT_WIRE_COLOR
});
}
return { circuit: { version: CIRCUIT_VERSION, boards, components, wires }, problems };
}
/**
* normalizeCircuitWithReport, discarding the report. Prefer the reporting form
* anywhere the user should be told what happened - notably on load.
* @returns {object} a circuit that passes validateCircuit()
*/
export function normalizeCircuit(raw) {
return normalizeCircuitWithReport(raw).circuit;
}
/**
* The exact payload to PUT to the server.
*
* The server-side validator whitelists keys strictly at every level and rejects any
* unknown property, so this rebuilds the document from scratch with only the
* permitted keys rather than trusting whatever the editor has been mutating. In
* particular there is NO slot for editor state - viewport, zoom, selection and tool
* are deliberately kept out of the document and persisted separately.
* @returns {object}
*/
export function serializeCircuit(circuit) {
return normalizeCircuit(circuit);
}
/** UTF-8 byte length of the serialized document, for the pre-save size check. */
export function circuitByteSize(circuit) {
const json = JSON.stringify(circuit);
if (typeof TextEncoder !== 'undefined') return new TextEncoder().encode(json).length;
let bytes = 0;
for (let i = 0; i < json.length; i++) {
const code = json.codePointAt(i);
if (code > 0xffff) { bytes += 4; i++; } else if (code > 0x7ff) { bytes += 3; }
else if (code > 0x7f) { bytes += 2; } else { bytes += 1; }
}
return bytes;
}
// --- Validation ---
function checkUid(uid, label, seen, errors) {
if (typeof uid !== 'string' || uid.length === 0) {
errors.push(`${label}: uid must be a non-empty string`);
return false;
}
if (!UID_PATTERN.test(uid)) {
errors.push(`${label}: uid must be 1-${MAX_UID_LENGTH} characters from A-Z a-z 0-9 _ . : -`);
return false;
}
if (seen.has(uid)) {
errors.push(`${label}: duplicate uid "${uid}"`);
return false;
}
seen.add(uid);
return true;
}
function checkHole(hole, boardUids, label, errors) {
if (!isValidHole(hole)) {
errors.push(`${label}: invalid hole reference`);
return false;
}
if (!boardUids.has(hole.board)) {
errors.push(`${label}: references unknown board "${hole.board}"`);
return false;
}
return true;
}
/**
* Full structural validation of a circuit document.
* @param {*} circuit
* @returns {{ok: boolean, errors: string[]}}
*/
export function validateCircuit(circuit) {
const errors = [];
if (!circuit || typeof circuit !== 'object' || Array.isArray(circuit)) {
return { ok: false, errors: ['circuit must be an object'] };
}
if (circuit.version !== CIRCUIT_VERSION) {
errors.push(`version must be ${CIRCUIT_VERSION}`);
}
if (!Array.isArray(circuit.boards) || circuit.boards.length === 0) {
return { ok: false, errors: errors.concat('boards must be a non-empty array') };
}
if (!Array.isArray(circuit.components) || !Array.isArray(circuit.wires)) {
if (!Array.isArray(circuit.components)) errors.push('components must be an array');
if (!Array.isArray(circuit.wires)) errors.push('wires must be an array');
return { ok: false, errors };
}
if (circuit.boards.length > MAX_BOARDS) errors.push(`boards: at most ${MAX_BOARDS} allowed`);
if (circuit.components.length > MAX_COMPONENTS) errors.push(`components: at most ${MAX_COMPONENTS} allowed`);
if (circuit.wires.length > MAX_WIRES) errors.push(`wires: at most ${MAX_WIRES} allowed`);
const seen = new Set();
const boardUids = new Set();
const suppliedRailPairs = new Set();
circuit.boards.forEach((board, i) => {
const label = `boards[${i}]`;
if (!board || typeof board !== 'object') {
errors.push(`${label}: must be an object`);
return;
}
if (!checkUid(board.uid, label, seen, errors)) return;
boardUids.add(board.uid);
if (!Number.isFinite(board.x) || !Number.isFinite(board.y)) {
errors.push(`${label}: x and y must be finite numbers`);
} else if (Math.abs(board.x) > MAX_BOARD_COORD || Math.abs(board.y) > MAX_BOARD_COORD) {
errors.push(`${label}: x and y must be within +/-${MAX_BOARD_COORD}`);
}
});
circuit.components.forEach((component, i) => {
const label = `components[${i}]`;
if (!component || typeof component !== 'object') {
errors.push(`${label}: must be an object`);
return;
}
if (!checkUid(component.uid, label, seen, errors)) return;
const def = getComponentDef(component.type);
if (def === null) {
errors.push(`${label}: unknown component type "${String(component.type)}"`);
return;
}
if (def.anchorless) {
if (component.anchor !== null && component.anchor !== undefined) {
errors.push(`${label}: ${def.type} must not have an anchor`);
}
} else if (!checkHole(component.anchor, boardUids, `${label}.anchor`, errors)) {
return;
} else {
if (def.anchorKinds && def.anchorKinds.indexOf(component.anchor.kind) === -1) {
errors.push(`${label}: ${def.type} cannot be anchored to a ${component.anchor.kind} hole`);
}
if (def.anchorRows && component.anchor.kind === 'main'
&& def.anchorRows.indexOf(component.anchor.row) === -1) {
errors.push(`${label}: ${def.type} must be anchored on row ${def.anchorRows.join(' or ')}`);
}
}
if (def.orientable) {
if (def.orientValues.indexOf(component.orient) === -1) {
errors.push(`${label}: orient must be one of ${def.orientValues.join(', ')}`);
} else if (def.dipStyle && component.anchor && component.anchor.kind === 'main'
&& component.orient !== dipOrientForRow(component.anchor.row)) {
// A DIP package's rotation is carried by its anchor row; a conflicting
// orient would make the document self-contradictory.
errors.push(`${label}: orient "${component.orient}" contradicts anchor row "${component.anchor.row}"`);
}
} else if (component.orient !== undefined) {
errors.push(`${label}: ${def.type} must not have an orient`);
}
for (const message of validateProps(component.type, component.props)) {
errors.push(`${label}: ${message}`);
}
const props = component.props || {};
if (component.type === 'resistor') {
checkHole(props.to, boardUids, `${label}.props.to`, errors);
}
if (component.type === 'powerSupply5V') {
if (!boardUids.has(props.board)) {
errors.push(`${label}: props.board references unknown board "${String(props.board)}"`);
} else {
// Two supplies on one rail pair is a contradiction the engine would
// have to resolve as spurious contention.
const pair = `${props.board}|${props.side}`;
if (suppliedRailPairs.has(pair)) {
errors.push(`${label}: the ${props.side} rail pair of board "${props.board}" already has a 5V supply`);
}
suppliedRailPairs.add(pair);
}
}
const unplaced = componentPinHoles(component).filter(p => p.hole === null).map(p => p.pin);
if (unplaced.length === 1) {
errors.push(`${label}: pin ${unplaced[0]} falls off the board`);
} else if (unplaced.length > 1) {
errors.push(`${label}: pins ${unplaced.join(', ')} fall off the board`);
}
});
circuit.wires.forEach((wire, i) => {
const label = `wires[${i}]`;
if (!wire || typeof wire !== 'object') {
errors.push(`${label}: must be an object`);
return;
}
if (!checkUid(wire.uid, label, seen, errors)) return;
const fromOk = checkHole(wire.from, boardUids, `${label}.from`, errors);
const toOk = checkHole(wire.to, boardUids, `${label}.to`, errors);
if (fromOk && toOk && holeKey(wire.from) === holeKey(wire.to)) {
errors.push(`${label}: both ends are the same hole`);
}
if (typeof wire.color !== 'string' || !HEX_COLOR.test(wire.color)) {
errors.push(`${label}: color must be a #rrggbb string`);
}
});
return { ok: errors.length === 0, errors };
}
/**
* World-space bounding box covering every board, used to frame the initial view.
* @returns {{x:number,y:number,w:number,h:number}}
*/
export function circuitBounds(circuit) {
if (circuit.boards.length === 0) {
return { x: 0, y: 0, w: BOARD_WIDTH, h: BOARD_HEIGHT };
}
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const board of circuit.boards) {
minX = Math.min(minX, board.x);
minY = Math.min(minY, board.y);
maxX = Math.max(maxX, board.x + BOARD_WIDTH);
maxY = Math.max(maxY, board.y + BOARD_HEIGHT);
}
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
}
export { componentPinHoles };
@@ -0,0 +1,239 @@
// Pin-to-hole mapping for every component type.
//
// PURE MODULE. No DOM, no globals. Imported by the editor, by the engine inside a
// module Worker, and mirrored structurally by the C# validator - so this file is the
// single source of truth for where a component's pins physically land.
//
// componentPinHoles() always returns one entry per pin of the type, in pin order,
// with array index 0 being pin 1. A pin whose hole would fall off the board comes
// back as `hole: null` - an unplaced pin, never an exception.
import {
MAIN_ROWS,
mainHole,
railHole,
offsetHole,
isValidHole,
sameStrip
} from './board-geometry.js';
import { getComponentDef, isChipType } from './component-registry.js';
const DIP8_PIN_COUNT = 16;
const DIP8_PINS_PER_SIDE = 8;
function entry(pin, hole) {
return { pin, hole: hole === undefined ? null : hole };
}
function nullPins(count) {
const pins = [];
for (let i = 1; i <= count; i++) pins.push(entry(i, null));
return pins;
}
function isMainAnchor(anchor) {
return isValidHole(anchor) && anchor.kind === 'main';
}
/** Row directly across the center channel from `row`, or null when there isn't one. */
function acrossChannel(row) {
const i = MAIN_ROWS.indexOf(row);
if (i === -1) return null;
return row === 'e' ? 'f' : (row === 'f' ? 'e' : null);
}
// --- Per-type mappings ---
function ledPins(component) {
const anchor = component.anchor;
if (!isValidHole(anchor)) return nullPins(2);
// The default lives in the registry so it cannot drift from what the schema writes.
const dir = component.orient || getComponentDef('led').defaultOrient;
return [entry(1, anchor), entry(2, offsetHole(anchor, dir, 1))];
}
function resistorPins(component) {
const anchor = isValidHole(component.anchor) ? component.anchor : null;
const to = component.props && isValidHole(component.props.to) ? component.props.to : null;
return [entry(1, anchor), entry(2, to)];
}
/**
* DIP-style package straddling the center channel, anchored at pin 1.
*
* The offsets come from `def.footprint.pins` in the registry rather than being
* recomputed here, so the published footprint table and this resolver cannot drift.
* Each entry gives a column offset `dCol` in the package's reading direction and a
* `side` of the channel.
*
* Anchoring on row 'e' reads left to right (notch at the left); anchoring on row 'f'
* is the identical package rotated 180 degrees, which is why only the sign of the
* column step changes - the pin numbering never does.
*/
function dipFootprint(component, footprint) {
const anchor = component.anchor;
if (!isMainAnchor(anchor)) return nullPins(footprint.pinCount);
const farRow = acrossChannel(anchor.row);
if (farRow === null) return nullPins(footprint.pinCount);
const { board, col, row } = anchor;
const step = row === 'e' ? 1 : -1;
return footprint.pins.map(spec => entry(
spec.pin,
mainHole(board, col + step * spec.dCol, spec.side === 'anchor' ? row : farRow)
));
}
/** Two-pin part whose second pin is one step from the anchor in `orient`. */
function orientStepFootprint(component, footprint, def) {
const anchor = component.anchor;
if (!isValidHole(anchor)) return nullPins(footprint.pinCount);
// The default lives in the registry so it cannot drift from what the schema writes.
const dir = component.orient || def.defaultOrient;
return footprint.pins.map(spec => entry(
spec.pin,
spec.at === 'anchor' ? anchor : offsetHole(anchor, dir, spec.steps)
));
}
/** Two-pin part whose second pin is a free hole reference carried in props. */
function endpointsFootprint(component, footprint) {
const props = component.props || {};
return footprint.pins.map(spec => {
const hole = spec.at === 'anchor' ? component.anchor : props[spec.prop];
return entry(spec.pin, isValidHole(hole) ? hole : null);
});
}
/**
* Anchorless part that clamps onto a rail pair. Returning real hole references keeps
* it uniform with every other component, so consumers need no special case.
*/
function railPairFootprint(component, footprint) {
const props = component.props || {};
const board = props.board;
const side = props.side;
if (typeof board !== 'string' || board.length === 0) return nullPins(footprint.pinCount);
// Validation is the single authority on `side`. Silently coercing an unrecognized
// value to 'top' here would make the pin map disagree with the validator, and the
// engine follows the pin map.
if (side !== 'top' && side !== 'bottom') return nullPins(footprint.pinCount);
const prefix = side === 'top' ? 'top' : 'bottom';
return footprint.pins.map(spec => entry(
spec.pin,
railHole(board, prefix + (spec.polarity === 'plus' ? 'Plus' : 'Minus'), spec.index)
));
}
/**
* Where each pin of a component physically lands.
*
* Dispatches on the registry's declarative footprint, so adding a component type is a
* registry edit unless it needs a genuinely new footprint kind.
* @param {object} component a circuit component: { type, anchor, orient?, props? }
* @returns {Array<{pin:number, hole:object|null}>} index 0 is pin 1; [] for an
* unknown type. `hole` is null for a pin that falls off the board.
*/
export function componentPinHoles(component) {
if (!component || typeof component !== 'object') return [];
const def = getComponentDef(component.type);
if (def === null) return [];
const footprint = def.footprint;
if (!footprint) return nullPins(def.pins.length);
switch (footprint.kind) {
case 'dip': return dipFootprint(component, footprint);
case 'orientStep': return orientStepFootprint(component, footprint, def);
case 'endpoints': return endpointsFootprint(component, footprint);
case 'railPair': return railPairFootprint(component, footprint);
default: return nullPins(footprint.pinCount);
}
}
/**
* Pin holes annotated with the pin names from the registry.
* @returns {Array<{pin:number, name:string, hole:object|null}>}
*/
export function componentPinsWithNames(component) {
const def = getComponentDef(component && component.type);
const holes = componentPinHoles(component);
return holes.map((h, i) => ({
pin: h.pin,
name: def && def.pins[i] ? def.pins[i].name : String(h.pin),
hole: h.hole
}));
}
/**
* Every hole a component occupies, skipping unplaced pins.
* @returns {object[]}
*/
export function componentHoles(component) {
return componentPinHoles(component)
.map(p => p.hole)
.filter(h => h !== null);
}
/**
* True when every pin of the component landed on a real hole.
* @returns {boolean}
*/
export function isFullyPlaced(component) {
const pins = componentPinHoles(component);
return pins.length > 0 && pins.every(p => p.hole !== null);
}
/**
* Pins that land in the same electrical strip as another pin of the same component.
*
* Structurally legal but almost always a mistake - an LED with both legs in one
* terminal strip can never light, because both ends sit on the same net. The editor
* warns on it and the engine can use it to explain a dead component. It is
* deliberately NOT a validation error.
* @returns {Array<[number, number]>} pairs of pin numbers sharing a strip
*/
export function componentSelfShorts(component) {
const pins = componentPinHoles(component).filter(p => p.hole !== null);
const bonded = new Set(
staticPinBonds(component).map(([a, b]) => a < b ? `${a}:${b}` : `${b}:${a}`)
);
const shorts = [];
for (let i = 0; i < pins.length; i++) {
for (let j = i + 1; j < pins.length; j++) {
const key = `${pins[i].pin}:${pins[j].pin}`;
if (bonded.has(key)) continue; // an intentional internal tie, not a fault
if (sameStrip(pins[i].hole, pins[j].hole)) shorts.push([pins[i].pin, pins[j].pin]);
}
}
return shorts;
}
/**
* Pin pairs a component ties together unconditionally, regardless of simulation
* state. The engine may safely union these at load time.
* @returns {Array<[number, number]>} pairs of pin numbers
*/
export function staticPinBonds(component) {
if (!component || component.type !== 'pushButton') return [];
return [[1, 2], [3, 4]];
}
/**
* Pin pairs a switch closes when it is on. Not for the engine's signal model - it is
* here so the editor can draw switch state consistently with the engine.
* @returns {Array<{control:number, pins:[number, number]}>} `control` is the switch
* number the user toggles (1..8 for a DIP, 1 for a push button)
*/
export function switchablePinBonds(component) {
if (!component) return [];
if (component.type === 'pushButton') return [{ control: 1, pins: [1, 3] }];
if (component.type === 'dipSwitch8') {
const bonds = [];
for (let k = 1; k <= DIP8_PINS_PER_SIDE; k++) {
bonds.push({ control: k, pins: [k, DIP8_PIN_COUNT + 1 - k] });
}
return bonds;
}
return [];
}
@@ -0,0 +1,477 @@
// Component type registry: metadata shared by the editor palette, the schema
// validator and the engine.
//
// PURE MODULE. No DOM, no globals, no dependencies. Runs in a module Worker and in
// bare node. Physical facts only - nothing theme-dependent lives here except LED
// colors, which are a property of the part rather than of the UI.
//
// Milestone 1 registry. New types bolt on by adding an entry; nothing else in the
// codebase enumerates types.
/** DIP logic chips available in milestone 1. All 14-pin. */
export const CHIP_TYPES = Object.freeze([
'74HC00', '74HC02', '74HC04', '74HC08', '74HC32', '74HC86', '74HC30'
]);
const CHIP_LABELS = Object.assign(Object.create(null), {
'74HC00': 'Quad 2-input NAND',
'74HC02': 'Quad 2-input NOR',
'74HC04': 'Hex inverter',
'74HC08': 'Quad 2-input AND',
'74HC32': 'Quad 2-input OR',
'74HC86': 'Quad 2-input XOR',
'74HC30': '8-input NAND'
});
Object.freeze(CHIP_LABELS);
/** Selectable LED colors. `hex` is the physical lens color, not a theme color. */
export const LED_COLORS = Object.freeze([
Object.freeze({ value: 'red', label: 'Red', hex: '#ff3b30' }),
Object.freeze({ value: 'green', label: 'Green', hex: '#34c759' }),
Object.freeze({ value: 'blue', label: 'Blue', hex: '#4a90ff' }),
Object.freeze({ value: 'yellow', label: 'Yellow', hex: '#ffd60a' }),
Object.freeze({ value: 'orange', label: 'Orange', hex: '#ff9f0a' }),
Object.freeze({ value: 'white', label: 'White', hex: '#f2f2f7' })
]);
const LED_COLOR_VALUES = Object.freeze(LED_COLORS.map(c => c.value));
/** Minimum / maximum resistance a resistor may be given, in ohms. */
export const RESISTOR_MIN_OHMS = 1;
export const RESISTOR_MAX_OHMS = 10000000;
/** Common resistor values offered in the property editor. */
export const RESISTOR_PRESETS = Object.freeze([100, 220, 330, 470, 1000, 2200, 4700, 10000, 100000]);
function numberedPins(count) {
const pins = [];
for (let i = 1; i <= count; i++) pins.push(Object.freeze({ pin: i, name: String(i) }));
return Object.freeze(pins);
}
function namedPins(names) {
return Object.freeze(names.map((name, i) => Object.freeze({ pin: i + 1, name })));
}
/**
* Orientations a DIP-style package may carry. The package's real rotation is derived
* from which side of the channel its anchor sits on (row 'e' reads left-to-right,
* row 'f' is the same package turned 180 degrees), and `orient` is kept consistent
* with that so the document never contradicts itself.
*/
export const DIP_ORIENTATIONS = Object.freeze(['right', 'left']);
/** The orient value implied by a DIP-style package's anchor row. */
export function dipOrientForRow(row) {
return row === 'f' ? 'left' : 'right';
}
/** The anchor row implied by a DIP-style package's orient. */
export function dipRowForOrient(orient) {
return orient === 'left' ? 'f' : 'e';
}
/**
* Pin offsets for a standard DIP package straddling the center channel.
*
* Declarative on purpose: this table IS the footprint contract, mirrored as data by
* the C# validator. Resolving it is component-pins' job, so the rule can never drift
* between the description and the implementation.
*
* `dCol` is a column offset from the anchor, applied in the package's reading
* direction; `side` says which side of the channel the pin sits on.
*/
function dipPinOffsets(count) {
const half = count / 2;
const pins = [];
for (let pin = 1; pin <= half; pin++) {
pins.push(Object.freeze({ pin, dCol: pin - 1, side: 'anchor' }));
}
for (let pin = half + 1; pin <= count; pin++) {
pins.push(Object.freeze({ pin, dCol: count - pin, side: 'far' }));
}
return Object.freeze(pins);
}
function chipDef(type) {
return {
type,
label: type,
description: CHIP_LABELS[type],
category: 'chip',
pins: numberedPins(14),
// A 14-pin DIP straddles the center channel; its body covers 7 columns.
bodyColumns: 7,
straddlesGap: true,
anchorRows: Object.freeze(['e', 'f']),
anchorKinds: Object.freeze(['main']),
anchorless: false,
orientable: true,
orientValues: DIP_ORIENTATIONS,
defaultOrient: 'right',
dipStyle: true,
footprint: Object.freeze({ kind: 'dip', pinCount: 14, pins: dipPinOffsets(14) }),
defaultProps: Object.freeze({}),
propSpecs: Object.freeze({})
};
}
/**
* Orientations a three-legged inline package may carry. Its legs run along a row, so
* only the two horizontal directions leave each leg in a strip of its own: a column's
* rows a-e are one strip, which would short two legs of any vertical placement.
*/
export const INLINE_ORIENTATIONS = Object.freeze(['left', 'right']);
/** Footprint for a part whose pins step away from the anchor in the orient direction. */
function orientStepFootprint(pinCount) {
const pins = [];
for (let pin = 1; pin <= pinCount; pin++) {
pins.push(pin === 1
? Object.freeze({ pin, at: 'anchor' })
: Object.freeze({ pin, at: 'orientStep', steps: pin - 1 }));
}
return Object.freeze({ kind: 'orientStep', pinCount, pins: Object.freeze(pins) });
}
/**
* The four transistors differ only in polarity and in what their legs are called; the
* package, the footprint and the placement rules are one part. Pin order is the
* physical TO-92 one, control terminal in the middle.
*/
function transistorDef(type, label, description, pinNames) {
return {
type,
label,
description,
category: 'semiconductor',
pins: namedPins(pinNames),
bodyColumns: 3,
straddlesGap: false,
anchorRows: null,
// A power rail is one continuous strip, so all three legs there would be common.
anchorKinds: Object.freeze(['main']),
anchorless: false,
orientable: true,
orientValues: INLINE_ORIENTATIONS,
defaultOrient: 'right',
dipStyle: false,
footprint: orientStepFootprint(3),
defaultProps: Object.freeze({}),
propSpecs: Object.freeze({})
};
}
const DEFS = Object.create(null);
function define(def) {
DEFS[def.type] = Object.freeze(def);
}
define({
type: 'led',
label: 'LED',
description: 'Light emitting diode. Anchor is the anode; the cathode sits one hole away.',
category: 'output',
pins: namedPins(['anode', 'cathode']),
bodyColumns: 1,
straddlesGap: false,
anchorRows: null, // any row
anchorKinds: Object.freeze(['main', 'rail']),
anchorless: false,
orientable: true,
orientValues: Object.freeze(['up', 'down', 'left', 'right']),
// 'right' is the only default that reaches a different strip from every main
// column and also works from a rail hole. 'down' would land in the SAME 5-hole
// terminal strip from 8 of 10 rows, giving a shorted LED that can never light.
defaultOrient: 'right',
dipStyle: false,
footprint: Object.freeze({
kind: 'orientStep',
pinCount: 2,
pins: Object.freeze([
Object.freeze({ pin: 1, at: 'anchor' }),
Object.freeze({ pin: 2, at: 'orientStep', steps: 1 })
])
}),
defaultProps: Object.freeze({ color: 'red' }),
propSpecs: Object.freeze({
color: Object.freeze({ kind: 'enum', values: LED_COLOR_VALUES, default: 'red', label: 'Color' })
})
});
define({
type: 'resistor',
label: 'Resistor',
description: 'Two-terminal resistor. The second terminal is a free hole reference, so it may span boards or reach a rail.',
category: 'passive',
pins: namedPins(['p1', 'p2']),
bodyColumns: 1,
straddlesGap: false,
anchorRows: null,
anchorKinds: Object.freeze(['main', 'rail']),
anchorless: false,
orientable: false,
footprint: Object.freeze({
kind: 'endpoints',
pinCount: 2,
pins: Object.freeze([
Object.freeze({ pin: 1, at: 'anchor' }),
Object.freeze({ pin: 2, at: 'prop', prop: 'to' })
])
}),
// `to` is a hole reference rather than a scalar, so it is not in propSpecs -
// circuit-schema validates it structurally.
defaultProps: Object.freeze({ ohms: 220 }),
propSpecs: Object.freeze({
ohms: Object.freeze({
kind: 'number', min: RESISTOR_MIN_OHMS, max: RESISTOR_MAX_OHMS,
integer: false, default: 220, label: 'Resistance', unit: 'Ω',
presets: RESISTOR_PRESETS
})
})
});
define({
type: 'diode',
label: 'Diode',
description: 'Signal diode (1N4148). Anchor is the anode; the banded cathode sits one hole away. Passes current one way only.',
category: 'semiconductor',
pins: namedPins(['anode', 'cathode']),
bodyColumns: 1,
straddlesGap: false,
anchorRows: null,
anchorKinds: Object.freeze(['main', 'rail']),
anchorless: false,
orientable: true,
orientValues: Object.freeze(['up', 'down', 'left', 'right']),
// Same reasoning as the LED: 'right' is the only default that reaches a different
// strip from every main column and also works from a rail hole.
defaultOrient: 'right',
dipStyle: false,
footprint: orientStepFootprint(2),
defaultProps: Object.freeze({}),
propSpecs: Object.freeze({})
});
const TRANSISTOR_DEFS = Object.freeze([
transistorDef('npn', 'NPN',
'NPN transistor (2N3904): emitter, base, collector. Conducts when the base is high and the emitter is the low side, so it switches a load to ground.',
['emitter', 'base', 'collector']),
transistorDef('pnp', 'PNP',
'PNP transistor (2N3906): emitter, base, collector. Conducts when the base is low and the emitter is the high side, so it switches a load to the supply.',
['emitter', 'base', 'collector']),
transistorDef('nmos', 'N-MOSFET',
'N-channel MOSFET (2N7000): source, gate, drain. Conducts when the gate is high and the source is the low side. The gate draws no current, so it needs a pull-down to stay off.',
['source', 'gate', 'drain']),
transistorDef('pmos', 'P-MOSFET',
'P-channel MOSFET (BS250): source, gate, drain. Conducts when the gate is low and the source is the high side. The gate draws no current, so it needs a pull-up to stay off.',
['source', 'gate', 'drain'])
]);
for (const def of TRANSISTOR_DEFS) define(def);
define({
type: 'pushButton',
label: 'Push button',
description: 'Momentary tactile switch straddling the center channel. Its two same-side pins are permanently tied; pressing bridges the two sides.',
category: 'input',
pins: namedPins(['a1', 'a2', 'b1', 'b2']),
bodyColumns: 3,
straddlesGap: true,
anchorRows: Object.freeze(['e', 'f']),
anchorKinds: Object.freeze(['main']),
anchorless: false,
orientable: true,
orientValues: DIP_ORIENTATIONS,
defaultOrient: 'right',
dipStyle: true,
// Pins 1/2 are tied inside the package, as are 3/4; pressing bridges the pairs.
// The tied pins sit two columns apart so each tie bonds two separate strips.
footprint: Object.freeze({
kind: 'dip',
pinCount: 4,
pins: Object.freeze([
Object.freeze({ pin: 1, dCol: 0, side: 'anchor' }),
Object.freeze({ pin: 2, dCol: 2, side: 'anchor' }),
Object.freeze({ pin: 3, dCol: 0, side: 'far' }),
Object.freeze({ pin: 4, dCol: 2, side: 'far' })
])
}),
defaultProps: Object.freeze({}),
propSpecs: Object.freeze({})
});
define({
type: 'dipSwitch8',
label: 'DIP switch (8)',
description: 'Eight independent switches straddling the center channel; switch k bridges the gap in its own column.',
category: 'input',
pins: numberedPins(16),
bodyColumns: 8,
straddlesGap: true,
anchorRows: Object.freeze(['e', 'f']),
anchorKinds: Object.freeze(['main']),
anchorless: false,
orientable: true,
orientValues: DIP_ORIENTATIONS,
defaultOrient: 'right',
dipStyle: true,
switchCount: 8,
footprint: Object.freeze({ kind: 'dip', pinCount: 16, pins: dipPinOffsets(16) }),
// Switch positions ARE persisted: they change what the circuit does, so they
// belong in the document that describes it. Bounded by construction - exactly 8
// booleans - which is what made this acceptable where a free-form slot was not.
defaultProps: Object.freeze({ on: Object.freeze([false, false, false, false, false, false, false, false]) }),
propSpecs: Object.freeze({
on: Object.freeze({
kind: 'boolArray', length: 8, label: 'Switches',
default: Object.freeze([false, false, false, false, false, false, false, false])
})
})
});
define({
type: 'powerSupply5V',
label: '5V supply',
description: 'Drives one rail pair: the plus rail to 5V and the minus rail to ground.',
category: 'power',
pins: namedPins(['v+', 'gnd']),
bodyColumns: 0,
straddlesGap: false,
anchorRows: null,
anchorKinds: null,
anchorless: true, // positioned by props.board + props.side
orientable: false,
footprint: Object.freeze({
kind: 'railPair',
pinCount: 2,
pins: Object.freeze([
Object.freeze({ pin: 1, at: 'rail', polarity: 'plus', index: 1 }),
Object.freeze({ pin: 2, at: 'rail', polarity: 'minus', index: 1 })
])
}),
defaultProps: Object.freeze({ side: 'top' }),
propSpecs: Object.freeze({
side: Object.freeze({ kind: 'enum', values: Object.freeze(['top', 'bottom']), default: 'top', label: 'Rail pair' })
})
});
for (const type of CHIP_TYPES) define(chipDef(type));
Object.freeze(DEFS);
/** All registered component type names, in palette order. */
export const COMPONENT_TYPES = Object.freeze([
'led', 'resistor', 'diode', ...TRANSISTOR_DEFS.map(d => d.type),
'pushButton', 'dipSwitch8', 'powerSupply5V', ...CHIP_TYPES
]);
/** Valid `orient` values for orientable components. */
export const ORIENTATIONS = Object.freeze(['up', 'down', 'left', 'right']);
/**
* Definition for a component type.
* @returns {object|null} frozen definition, or null for an unknown type
*/
export function getComponentDef(type) {
return DEFS[type] || null;
}
/** True when `type` is a registered component type. */
export function isKnownType(type) {
return typeof type === 'string' && DEFS[type] !== undefined;
}
/** True when `type` is one of the DIP logic chips. */
export function isChipType(type) {
return CHIP_TYPES.indexOf(type) !== -1;
}
/** Number of pins a component type has. */
export function pinCount(type) {
const def = DEFS[type];
return def ? def.pins.length : 0;
}
/** Physical lens color for an LED color name; falls back to red. */
export function ledColorHex(value) {
const found = LED_COLORS.find(c => c.value === value);
return found ? found.hex : LED_COLORS[0].hex;
}
/**
* Props filled in with the type's defaults. Returns a fresh mutable object; array
* defaults (the DIP switch state) are copied so callers cannot mutate the registry.
* @returns {object}
*/
export function defaultPropsFor(type) {
const def = DEFS[type];
if (!def) return {};
const props = {};
for (const [key, value] of Object.entries(def.defaultProps)) {
props[key] = Array.isArray(value) ? value.slice() : value;
}
return props;
}
/**
* Validate a component's scalar props against the type's propSpecs. Hole-reference
* props (resistor `to`) and placement rules are checked by circuit-schema instead.
* @returns {string[]} error messages, empty when valid
*/
export function validateProps(type, props) {
const def = DEFS[type];
if (!def) return [`unknown component type "${String(type)}"`];
if (props === undefined || props === null) return [];
if (typeof props !== 'object' || Array.isArray(props)) return ['props must be an object'];
const errors = [];
for (const [key, spec] of Object.entries(def.propSpecs)) {
const value = props[key];
if (value === undefined) continue; // absent props fall back to defaults
if (spec.kind === 'enum' && spec.values.indexOf(value) === -1) {
errors.push(`${type}.${key} must be one of ${spec.values.join(', ')}`);
} else if (spec.kind === 'number') {
if (typeof value !== 'number' || !Number.isFinite(value)) {
errors.push(`${type}.${key} must be a finite number`);
} else if (value < spec.min || value > spec.max) {
errors.push(`${type}.${key} must be between ${spec.min} and ${spec.max}`);
}
} else if (spec.kind === 'boolArray') {
if (!Array.isArray(value)) {
errors.push(`${type}.${key} must be an array`);
} else if (value.length !== spec.length) {
errors.push(`${type}.${key} must have exactly ${spec.length} entries`);
} else if (value.some(v => typeof v !== 'boolean')) {
errors.push(`${type}.${key} entries must all be true or false`);
}
}
}
// The server-side validator whitelists props keys per type and rejects anything
// else, so catch a stray key here rather than as a 400 after the save is sent.
const allowed = allowedPropKeys(type);
for (const key of Object.keys(props)) {
if (allowed.indexOf(key) === -1) {
errors.push(`${type}.${key} is not a valid property`);
}
}
return errors;
}
// Props that are hole references rather than scalars, so they have no propSpec entry
// but are still permitted by the server.
const EXTRA_PROP_KEYS = Object.freeze(Object.assign(Object.create(null), {
resistor: Object.freeze(['to']),
powerSupply5V: Object.freeze(['board'])
}));
/**
* Every props key a component type may carry in a persisted document.
* @returns {string[]}
*/
export function allowedPropKeys(type) {
const def = DEFS[type];
if (!def) return [];
return Object.keys(def.propSpecs).concat(EXTRA_PROP_KEYS[type] || []);
}
@@ -4,11 +4,10 @@
// --- Providers --- // --- Providers ---
app.loadBills = async function () { app.loadBills = async function () {
if (!state.selectedPersonId) return; const personParam = state.selectedPersonId ? `?personId=${state.selectedPersonId}` : '';
const [providersRes, summaryRes] = await Promise.all([ const [providersRes, summaryRes] = await Promise.all([
fetch(`${app.API}/providers?personId=${state.selectedPersonId}`), fetch(`${app.API}/providers${personParam}`),
fetch(`${app.API}/bills/summary?personId=${state.selectedPersonId}`) fetch(`${app.API}/bills/summary${personParam}`)
]); ]);
if (providersRes.ok) { if (providersRes.ok) {
@@ -78,7 +77,8 @@
if (!container) return; if (!container) return;
// Fetch unassigned bills // Fetch unassigned bills
const unassignedRes = await fetch(`${app.API}/bills?personId=${state.selectedPersonId}`); const unassignedBillParam = state.selectedPersonId ? `?personId=${state.selectedPersonId}` : '';
const unassignedRes = await fetch(`${app.API}/bills${unassignedBillParam}`);
let unassignedBills = []; let unassignedBills = [];
if (unassignedRes.ok) { if (unassignedRes.ok) {
const allBills = await unassignedRes.json(); const allBills = await unassignedRes.json();
@@ -99,7 +99,7 @@
<div class="prescription-info"> <div class="prescription-info">
<div class="prescription-name"> <div class="prescription-name">
<span class="expand-btn" id="provider-expand-${p.id}">&#9654;</span> <span class="expand-btn" id="provider-expand-${p.id}">&#9654;</span>
${app.escapeHtml(p.name)} ${app.personBadge(p.personId)}${app.escapeHtml(p.name)}
<span class="bill-status ${statusClass}">${statusLabel}</span> <span class="bill-status ${statusClass}">${statusLabel}</span>
</div> </div>
<div class="prescription-meta"> <div class="prescription-meta">
@@ -164,8 +164,9 @@
const section = document.getElementById(`provider-details-${providerId}`); const section = document.getElementById(`provider-details-${providerId}`);
if (!section) return; if (!section) return;
const billsParam = state.selectedPersonId ? `personId=${state.selectedPersonId}&` : '';
const [billsRes, paymentsRes] = await Promise.all([ const [billsRes, paymentsRes] = await Promise.all([
fetch(`${app.API}/bills?personId=${state.selectedPersonId}&providerId=${providerId}`), fetch(`${app.API}/bills?${billsParam}providerId=${providerId}`),
fetch(`${app.API}/providers/${providerId}/payments`) fetch(`${app.API}/providers/${providerId}/payments`)
]); ]);
@@ -192,6 +193,8 @@
return `<option value="${d.id}">${app.escapeHtml(label)}</option>`; return `<option value="${d.id}">${app.escapeHtml(label)}</option>`;
}).join(''); }).join('');
const showForms = state.selectedPersonId ? '' : 'style="display:none"';
const billsHtml = bills.map(b => { const billsHtml = bills.map(b => {
const meta = []; const meta = [];
if (b.billDate) meta.push(app.formatDate(b.billDate)); if (b.billDate) meta.push(app.formatDate(b.billDate));
@@ -213,7 +216,7 @@
${docNames ? `<div style="margin-left:18px;">${docNames}</div>` : ''} ${docNames ? `<div style="margin-left:18px;">${docNames}</div>` : ''}
<div id="bill-charges-${b.id}" style="display:none;margin-left:18px;margin-top:4px;"> <div id="bill-charges-${b.id}" style="display:none;margin-left:18px;margin-top:4px;">
<div class="charges-header">Charges</div> <div class="charges-header">Charges</div>
<div class="pickup-form"> <div class="pickup-form" ${showForms}>
<div class="inline-form-row"> <div class="inline-form-row">
<input type="text" id="chargeDesc-${b.id}" placeholder="Description *" class="form-input" /> <input type="text" id="chargeDesc-${b.id}" placeholder="Description *" class="form-input" />
<input type="number" id="chargeAmount-${b.id}" placeholder="Amount *" class="form-input" step="0.01" min="0.01" /> <input type="number" id="chargeAmount-${b.id}" placeholder="Amount *" class="form-input" step="0.01" min="0.01" />
@@ -221,8 +224,8 @@
</div> </div>
</div> </div>
<div class="charge-list" id="chargeList-${b.id}"></div> <div class="charge-list" id="chargeList-${b.id}"></div>
<div class="section-divider"></div> <div class="section-divider" ${showForms}></div>
<div class="pickup-form"> <div class="pickup-form" ${showForms}>
<div class="inline-form-row"> <div class="inline-form-row">
<select id="linkDoc-${b.id}" class="form-input"> <select id="linkDoc-${b.id}" class="form-input">
<option value="">Link document...</option> <option value="">Link document...</option>
@@ -251,7 +254,7 @@
section.innerHTML = ` section.innerHTML = `
<div class="charges-section"> <div class="charges-section">
<div class="charges-header">Bills</div> <div class="charges-header">Bills</div>
<div class="pickup-form"> <div class="pickup-form" ${showForms}>
<div class="inline-form-row"> <div class="inline-form-row">
<input type="number" id="newBillAmount-${providerId}" placeholder="Amount *" class="form-input" step="0.01" min="0.01" /> <input type="number" id="newBillAmount-${providerId}" placeholder="Amount *" class="form-input" step="0.01" min="0.01" />
<input type="text" id="newBillSummary-${providerId}" placeholder="Summary" class="form-input" /> <input type="text" id="newBillSummary-${providerId}" placeholder="Summary" class="form-input" />
@@ -265,7 +268,7 @@
<div class="section-divider"></div> <div class="section-divider"></div>
<div class="payments-section"> <div class="payments-section">
<div class="charges-header">Payments</div> <div class="charges-header">Payments</div>
<div class="pickup-form"> <div class="pickup-form" ${showForms}>
<div class="inline-form-row"> <div class="inline-form-row">
<input type="number" id="provPayAmount-${providerId}" placeholder="Amount *" class="form-input" step="0.01" min="0.01" /> <input type="number" id="provPayAmount-${providerId}" placeholder="Amount *" class="form-input" step="0.01" min="0.01" />
<input type="date" id="provPayDate-${providerId}" class="form-input" title="Payment date" /> <input type="date" id="provPayDate-${providerId}" class="form-input" title="Payment date" />
@@ -281,7 +284,8 @@
const section = document.getElementById('provider-details-unassigned'); const section = document.getElementById('provider-details-unassigned');
if (!section) return; if (!section) return;
const res = await fetch(`${app.API}/bills?personId=${state.selectedPersonId}`); const unassignedParam = state.selectedPersonId ? `?personId=${state.selectedPersonId}` : '';
const res = await fetch(`${app.API}/bills${unassignedParam}`);
if (!res.ok) return; if (!res.ok) return;
const allBills = await res.json(); const allBills = await res.json();
const bills = allBills.filter(b => !b.providerId); const bills = allBills.filter(b => !b.providerId);
@@ -531,7 +535,8 @@
if (!providerId) { alert('Select a provider'); return; } if (!providerId) { alert('Select a provider'); return; }
// Get current bill details first // Get current bill details first
const getRes = await fetch(`${app.API}/bills?personId=${state.selectedPersonId}`); const assignParam = state.selectedPersonId ? `?personId=${state.selectedPersonId}` : '';
const getRes = await fetch(`${app.API}/bills${assignParam}`);
if (!getRes.ok) return; if (!getRes.ok) return;
const allBills = await getRes.json(); const allBills = await getRes.json();
const bill = allBills.find(b => b.id === billId); const bill = allBills.find(b => b.id === billId);
@@ -561,9 +566,10 @@
app.refreshProvider = async function (providerId) { app.refreshProvider = async function (providerId) {
// Refresh the summary and provider header, then reload details // Refresh the summary and provider header, then reload details
const refreshParam = state.selectedPersonId ? `?personId=${state.selectedPersonId}` : '';
const [providersRes, summaryRes] = await Promise.all([ const [providersRes, summaryRes] = await Promise.all([
fetch(`${app.API}/providers?personId=${state.selectedPersonId}`), fetch(`${app.API}/providers${refreshParam}`),
fetch(`${app.API}/bills/summary?personId=${state.selectedPersonId}`) fetch(`${app.API}/bills/summary${refreshParam}`)
]); ]);
if (providersRes.ok) { if (providersRes.ok) {
@@ -2,8 +2,8 @@
const { state } = app; const { state } = app;
app.loadConditions = async function () { app.loadConditions = async function () {
if (!state.selectedPersonId) return; const personParam = state.selectedPersonId ? `?personId=${state.selectedPersonId}` : '';
const res = await fetch(`${app.API}/conditions?personId=${state.selectedPersonId}`); const res = await fetch(`${app.API}/conditions${personParam}`);
if (!res.ok) return; if (!res.ok) return;
const conditions = await res.json(); const conditions = await res.json();
app.renderConditions(conditions); app.renderConditions(conditions);
@@ -24,7 +24,7 @@
: '<span class="badge-inactive">Inactive</span>'; : '<span class="badge-inactive">Inactive</span>';
return `<div class="condition-item" id="condition-${c.id}"> return `<div class="condition-item" id="condition-${c.id}">
<div class="condition-info"> <div class="condition-info">
<div class="condition-name">${app.escapeHtml(c.name)} ${statusBadge}</div> <div class="condition-name">${app.personBadge(c.personId)}${app.escapeHtml(c.name)} ${statusBadge}</div>
${meta.length ? `<div class="condition-meta">${meta.join(' &middot; ')}</div>` : ''} ${meta.length ? `<div class="condition-meta">${meta.join(' &middot; ')}</div>` : ''}
${c.notes ? `<div class="condition-meta">${app.escapeHtml(c.notes)}</div>` : ''} ${c.notes ? `<div class="condition-meta">${app.escapeHtml(c.notes)}</div>` : ''}
</div> </div>
@@ -64,7 +64,8 @@
}; };
app.toggleConditionActive = async function (id, isActive) { app.toggleConditionActive = async function (id, isActive) {
const res = await fetch(`${app.API}/conditions?personId=${state.selectedPersonId}`); const toggleParam = state.selectedPersonId ? `?personId=${state.selectedPersonId}` : '';
const res = await fetch(`${app.API}/conditions${toggleParam}`);
if (!res.ok) return; if (!res.ok) return;
const conditions = await res.json(); const conditions = await res.json();
const condition = conditions.find(c => c.id === id); const condition = conditions.find(c => c.id === id);
@@ -2,7 +2,8 @@
const { state } = app; const { state } = app;
app.loadDoctors = async function () { app.loadDoctors = async function () {
const res = await fetch(`${app.API}/doctors`); const personParam = state.selectedPersonId ? `?personId=${state.selectedPersonId}` : '';
const res = await fetch(`${app.API}/doctors${personParam}`);
if (!res.ok) return; if (!res.ok) return;
state.doctors = await res.json(); state.doctors = await res.json();
app.renderDoctors(); app.renderDoctors();
@@ -26,7 +27,7 @@
return `<div class="doctor-card-wrapper" id="doctor-wrapper-${doc.id}"> return `<div class="doctor-card-wrapper" id="doctor-wrapper-${doc.id}">
<div class="doctor-card" id="doctor-${doc.id}"> <div class="doctor-card" id="doctor-${doc.id}">
<div class="doctor-info"> <div class="doctor-info">
<div class="doctor-name">${app.escapeHtml(doc.name)}</div> <div class="doctor-name">${app.personBadge(doc.personId)}${app.escapeHtml(doc.name)}</div>
<div class="doctor-details">${details.map(d => app.escapeHtml(d)).join(' &middot; ')}</div> <div class="doctor-details">${details.map(d => app.escapeHtml(d)).join(' &middot; ')}</div>
${doc.notes ? `<div class="doctor-notes">${app.escapeHtml(doc.notes)}</div>` : ''} ${doc.notes ? `<div class="doctor-notes">${app.escapeHtml(doc.notes)}</div>` : ''}
</div> </div>
@@ -68,6 +69,7 @@
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
personId: state.selectedPersonId,
name, name,
specialty: document.getElementById('newDoctorSpecialty').value.trim() || null, specialty: document.getElementById('newDoctorSpecialty').value.trim() || null,
phone: document.getElementById('newDoctorPhone').value.trim() || null, phone: document.getElementById('newDoctorPhone').value.trim() || null,
@@ -5,7 +5,7 @@
app.buildFilterQuery = function () { app.buildFilterQuery = function () {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set('personId', state.selectedPersonId); if (state.selectedPersonId) params.set('personId', state.selectedPersonId);
const search = document.getElementById('filterSearch').value.trim(); const search = document.getElementById('filterSearch').value.trim();
if (search) params.set('search', search); if (search) params.set('search', search);
@@ -47,8 +47,8 @@
}; };
app.loadFilterTags = async function () { app.loadFilterTags = async function () {
if (!state.selectedPersonId) return; const personParam = state.selectedPersonId ? `?personId=${state.selectedPersonId}` : '';
const res = await fetch(`${app.API}/tags?personId=${state.selectedPersonId}`); const res = await fetch(`${app.API}/tags${personParam}`);
if (!res.ok) return; if (!res.ok) return;
const tags = await res.json(); const tags = await res.json();
const select = document.getElementById('filterTag'); const select = document.getElementById('filterTag');
@@ -59,8 +59,8 @@
}; };
app.loadFilterConditions = async function () { app.loadFilterConditions = async function () {
if (!state.selectedPersonId) return; const personParam = state.selectedPersonId ? `?personId=${state.selectedPersonId}` : '';
const res = await fetch(`${app.API}/conditions?personId=${state.selectedPersonId}`); const res = await fetch(`${app.API}/conditions${personParam}`);
if (!res.ok) return; if (!res.ok) return;
const conditions = await res.json(); const conditions = await res.json();
const select = document.getElementById('filterCondition'); const select = document.getElementById('filterCondition');
@@ -93,8 +93,6 @@
// --- Documents --- // --- Documents ---
app.loadDocuments = async function () { app.loadDocuments = async function () {
if (!state.selectedPersonId) return;
const query = app.buildFilterQuery(); const query = app.buildFilterQuery();
const res = await fetch(`${app.API}/documents/search?${query}`); const res = await fetch(`${app.API}/documents/search?${query}`);
if (!res.ok) return; if (!res.ok) return;
@@ -172,7 +170,7 @@
<div class="doc-info"> <div class="doc-info">
<div class="doc-title"> <div class="doc-title">
<span class="expand-btn" id="doc-expand-${doc.id}">&#9654;</span> <span class="expand-btn" id="doc-expand-${doc.id}">&#9654;</span>
${app.escapeHtml(displayTitle)} ${app.personBadge(doc.personId)}${app.escapeHtml(displayTitle)}
</div> </div>
<div class="doc-meta"> <div class="doc-meta">
<span>${meta.join(' &middot; ')}</span> <span>${meta.join(' &middot; ')}</span>

Some files were not shown because too many files have changed in this diff Show More