Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c4ff64070 | ||
|
|
639ace0030 | ||
|
|
cb0df5728c | ||
|
|
0393dacf63 | ||
|
|
7bbd679171 | ||
|
|
e3d9c8f273 | ||
|
|
64107d2e52 | ||
|
|
15a84b2027 | ||
|
|
f1e2aad8bf | ||
|
|
cb86e15cc0 | ||
|
|
9b182eaedf | ||
|
|
9f3f610025 | ||
|
|
a3c90ad06e | ||
|
|
7dc70767d2 | ||
|
|
bb35460097 | ||
|
|
dea8d1bf90 | ||
|
|
d0c8c1249b | ||
|
|
7e6ce61f03 | ||
|
|
e971a5f329 | ||
|
|
91ef4f41dd | ||
|
|
e3d94b9f43 | ||
|
|
cfd6bb530b | ||
|
|
c173677f57 | ||
|
|
dda6ac68dc | ||
|
|
af309ca8d6 |
@@ -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": []
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">×</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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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) */
|
||||||
/* ======================== */
|
/* ======================== */
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = ``;
|
||||||
|
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)} MΩ`;
|
||||||
|
if (ohms >= 1000) return `${trimZeros(ohms / 1000)} kΩ`;
|
||||||
|
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}">▶</span>
|
<span class="expand-btn" id="provider-expand-${p.id}">▶</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(' · ')}</div>` : ''}
|
${meta.length ? `<div class="condition-meta">${meta.join(' · ')}</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(' · ')}</div>
|
<div class="doctor-details">${details.map(d => app.escapeHtml(d)).join(' · ')}</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}">▶</span>
|
<span class="expand-btn" id="doc-expand-${doc.id}">▶</span>
|
||||||
${app.escapeHtml(displayTitle)}
|
${app.personBadge(doc.personId)}${app.escapeHtml(displayTitle)}
|
||||||
</div>
|
</div>
|
||||||
<div class="doc-meta">
|
<div class="doc-meta">
|
||||||
<span>${meta.join(' · ')}</span>
|
<span>${meta.join(' · ')}</span>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user