Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
caad111457 |
@@ -11,9 +11,7 @@
|
|||||||
"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,77 @@
|
|||||||
|
# .gitea/workflows/deploy.yml
|
||||||
|
name: Deploy Media Server
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "master" ]
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-media-${{ gitea.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Install toolchain
|
||||||
|
run: |
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --no-install-recommends rsync openssh-client
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: '8.0.x'
|
||||||
|
|
||||||
|
- name: Restore
|
||||||
|
run: dotnet restore
|
||||||
|
|
||||||
|
- name: Publish
|
||||||
|
run: dotnet publish -c Release -o ./publish
|
||||||
|
|
||||||
|
- name: Prepare SSH
|
||||||
|
env:
|
||||||
|
SSH_KEY: ${{ secrets.MEDIA_SSH_KEY }}
|
||||||
|
SSH_HOST: ${{ secrets.MEDIA_SSH_HOST }}
|
||||||
|
SSH_PORT: ${{ secrets.MEDIA_SSH_PORT }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
PORT="${SSH_PORT:-22}"
|
||||||
|
install -m 700 -d ~/.ssh
|
||||||
|
printf '%s\n' "$SSH_KEY" > ~/.ssh/deploy_key
|
||||||
|
chmod 600 ~/.ssh/deploy_key
|
||||||
|
ssh-keyscan -p "$PORT" "$SSH_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
|
||||||
|
# Staged through /tmp because the deploy user can't write /var/www directly.
|
||||||
|
# /usr/local/sbin/deploy-media (root-owned, the only command in deploy's
|
||||||
|
# sudoers) moves it into place with --exclude=App_Data, so the uploaded
|
||||||
|
# media and its encrypted store are never touched by --delete.
|
||||||
|
- name: Stage publish output
|
||||||
|
env:
|
||||||
|
SSH_HOST: ${{ secrets.MEDIA_SSH_HOST }}
|
||||||
|
SSH_PORT: ${{ secrets.MEDIA_SSH_PORT }}
|
||||||
|
SSH_USER: ${{ secrets.MEDIA_SSH_USER }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
PORT="${SSH_PORT:-22}"
|
||||||
|
SSH_OPTS="-p $PORT -i $HOME/.ssh/deploy_key -o StrictHostKeyChecking=yes"
|
||||||
|
ssh $SSH_OPTS "$SSH_USER@$SSH_HOST" \
|
||||||
|
"rm -rf /tmp/media-app-stage && mkdir -p /tmp/media-app-stage"
|
||||||
|
rsync -az --delete -e "ssh $SSH_OPTS" \
|
||||||
|
publish/ "$SSH_USER@$SSH_HOST:/tmp/media-app-stage/"
|
||||||
|
|
||||||
|
- name: Install and restart
|
||||||
|
env:
|
||||||
|
SSH_HOST: ${{ secrets.MEDIA_SSH_HOST }}
|
||||||
|
SSH_PORT: ${{ secrets.MEDIA_SSH_PORT }}
|
||||||
|
SSH_USER: ${{ secrets.MEDIA_SSH_USER }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
PORT="${SSH_PORT:-22}"
|
||||||
|
ssh -p "$PORT" -i "$HOME/.ssh/deploy_key" "$SSH_USER@$SSH_HOST" \
|
||||||
|
"sudo -n /usr/local/sbin/deploy-media && rm -rf /tmp/media-app-stage"
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
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);
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
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(userId.Value);
|
var people = await medicalDocsService.GetPeopleAsync();
|
||||||
return Ok(people);
|
return Ok(people);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,68 +31,13 @@ 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(userId.Value, request.Name.Trim(), request.DateOfBirth, request.Notes);
|
var person = await medicalDocsService.CreatePersonAsync(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")]
|
||||||
@@ -101,7 +46,6 @@ 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;
|
||||||
@@ -112,7 +56,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 = null,
|
[FromQuery] long personId,
|
||||||
[FromQuery] string? search = null,
|
[FromQuery] string? search = null,
|
||||||
[FromQuery] string? classification = null,
|
[FromQuery] string? classification = null,
|
||||||
[FromQuery] string? documentType = null,
|
[FromQuery] string? documentType = null,
|
||||||
@@ -128,24 +72,28 @@ 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, accessUserId: personId.HasValue ? null : userId, offset: offset, limit: limit);
|
var documents = await medicalDocsService.SearchDocumentsAsync(personId, search, classification, documentType, doctorId, tagId, conditionId, fromDate, toDate, aiProcessed, offset, limit);
|
||||||
return Ok(documents);
|
return Ok(documents);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("tags")]
|
[HttpGet("tags")]
|
||||||
public async Task<IActionResult> GetPersonTags([FromQuery] long? personId = null)
|
public async Task<IActionResult> GetPersonTags([FromQuery] long personId)
|
||||||
{
|
{
|
||||||
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 tags = await medicalDocsService.GetPersonTagsAsync(personId, accessUserId: personId.HasValue ? null : userId);
|
if (personId <= 0)
|
||||||
|
return BadRequest(new { error = "personId is required" });
|
||||||
|
|
||||||
|
var tags = await medicalDocsService.GetPersonTagsAsync(personId);
|
||||||
return Ok(tags);
|
return Ok(tags);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +104,6 @@ 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" });
|
||||||
@@ -179,7 +126,6 @@ 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" });
|
||||||
|
|
||||||
@@ -198,7 +144,6 @@ 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" });
|
||||||
@@ -212,7 +157,6 @@ 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" });
|
||||||
@@ -232,7 +176,6 @@ 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" });
|
||||||
@@ -246,7 +189,6 @@ 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" });
|
||||||
@@ -262,7 +204,6 @@ 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" });
|
||||||
@@ -319,23 +260,21 @@ 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 (shared, no per-person access check) ---
|
// --- Doctors ---
|
||||||
|
|
||||||
[HttpGet("doctors")]
|
[HttpGet("doctors")]
|
||||||
public async Task<IActionResult> GetDoctors([FromQuery] long? personId = null)
|
public async Task<IActionResult> GetDoctors()
|
||||||
{
|
{
|
||||||
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(personId, accessUserId: personId.HasValue ? null : userId);
|
var doctors = await medicalDocsService.GetDoctorsAsync();
|
||||||
return Ok(doctors);
|
return Ok(doctors);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,12 +284,11 @@ 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.PersonId, request.Name.Trim(), request.Specialty, request.Phone, request.Address, request.Notes);
|
var doctor = await medicalDocsService.CreateDoctorAsync(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" });
|
||||||
|
|
||||||
@@ -363,7 +301,6 @@ 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" });
|
||||||
@@ -380,7 +317,6 @@ 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" });
|
||||||
@@ -391,14 +327,16 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
|
|||||||
// --- Conditions ---
|
// --- Conditions ---
|
||||||
|
|
||||||
[HttpGet("conditions")]
|
[HttpGet("conditions")]
|
||||||
public async Task<IActionResult> GetConditions([FromQuery] long? personId = null)
|
public async Task<IActionResult> GetConditions([FromQuery] long personId)
|
||||||
{
|
{
|
||||||
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 conditions = await medicalDocsService.GetConditionsAsync(personId, accessUserId: personId.HasValue ? null : userId);
|
if (personId <= 0)
|
||||||
|
return BadRequest(new { error = "personId is required" });
|
||||||
|
|
||||||
|
var conditions = await medicalDocsService.GetConditionsAsync(personId);
|
||||||
return Ok(conditions);
|
return Ok(conditions);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,7 +349,6 @@ 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" });
|
||||||
|
|
||||||
@@ -428,7 +365,6 @@ 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" });
|
||||||
@@ -445,7 +381,6 @@ 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" });
|
||||||
@@ -456,14 +391,16 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
|
|||||||
// --- Prescriptions ---
|
// --- Prescriptions ---
|
||||||
|
|
||||||
[HttpGet("prescriptions")]
|
[HttpGet("prescriptions")]
|
||||||
public async Task<IActionResult> GetPrescriptions([FromQuery] long? personId = null)
|
public async Task<IActionResult> GetPrescriptions([FromQuery] long personId)
|
||||||
{
|
{
|
||||||
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 prescriptions = await medicalDocsService.GetPrescriptionsAsync(personId, accessUserId: personId.HasValue ? null : userId);
|
if (personId <= 0)
|
||||||
|
return BadRequest(new { error = "personId is required" });
|
||||||
|
|
||||||
|
var prescriptions = await medicalDocsService.GetPrescriptionsAsync(personId);
|
||||||
return Ok(prescriptions);
|
return Ok(prescriptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,7 +413,6 @@ 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" });
|
||||||
|
|
||||||
@@ -493,7 +429,6 @@ 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" });
|
||||||
@@ -510,7 +445,6 @@ 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" });
|
||||||
@@ -526,7 +460,6 @@ 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);
|
||||||
@@ -538,7 +471,6 @@ 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)
|
||||||
@@ -553,7 +485,6 @@ 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" });
|
||||||
@@ -564,14 +495,16 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
|
|||||||
// --- Billing Providers ---
|
// --- Billing Providers ---
|
||||||
|
|
||||||
[HttpGet("providers")]
|
[HttpGet("providers")]
|
||||||
public async Task<IActionResult> GetProviders([FromQuery] long? personId = null)
|
public async Task<IActionResult> GetProviders([FromQuery] long personId)
|
||||||
{
|
{
|
||||||
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 providers = await medicalDocsService.GetProvidersAsync(personId, accessUserId: personId.HasValue ? null : userId);
|
if (personId <= 0)
|
||||||
|
return BadRequest(new { error = "personId is required" });
|
||||||
|
|
||||||
|
var providers = await medicalDocsService.GetProvidersAsync(personId);
|
||||||
return Ok(providers);
|
return Ok(providers);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -584,7 +517,6 @@ 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" });
|
||||||
|
|
||||||
@@ -601,7 +533,6 @@ 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" });
|
||||||
@@ -618,7 +549,6 @@ 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" });
|
||||||
@@ -634,7 +564,6 @@ 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);
|
||||||
@@ -646,7 +575,6 @@ 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" });
|
||||||
@@ -664,7 +592,6 @@ 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" });
|
||||||
@@ -675,14 +602,16 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
|
|||||||
// --- Bills ---
|
// --- Bills ---
|
||||||
|
|
||||||
[HttpGet("bills")]
|
[HttpGet("bills")]
|
||||||
public async Task<IActionResult> GetBills([FromQuery] long? personId = null, [FromQuery] long? providerId = null)
|
public async Task<IActionResult> GetBills([FromQuery] long personId, [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();
|
|
||||||
|
|
||||||
var bills = await medicalDocsService.GetBillsAsync(personId, providerId, accessUserId: personId.HasValue ? null : userId);
|
if (personId <= 0)
|
||||||
|
return BadRequest(new { error = "personId is required" });
|
||||||
|
|
||||||
|
var bills = await medicalDocsService.GetBillsAsync(personId, providerId);
|
||||||
return Ok(bills);
|
return Ok(bills);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -695,7 +624,6 @@ 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" });
|
||||||
|
|
||||||
@@ -712,7 +640,6 @@ 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" });
|
||||||
@@ -729,7 +656,6 @@ 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" });
|
||||||
@@ -743,7 +669,6 @@ 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" });
|
||||||
@@ -761,7 +686,6 @@ 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" });
|
||||||
@@ -777,7 +701,6 @@ 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);
|
||||||
@@ -789,7 +712,6 @@ 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" });
|
||||||
@@ -809,7 +731,6 @@ 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" });
|
||||||
@@ -820,17 +741,19 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
|
|||||||
// --- Timeline ---
|
// --- Timeline ---
|
||||||
|
|
||||||
[HttpGet("timeline")]
|
[HttpGet("timeline")]
|
||||||
public async Task<IActionResult> GetTimeline([FromQuery] long? personId = null, [FromQuery] int offset = 0, [FromQuery] int limit = 100)
|
public async Task<IActionResult> GetTimeline([FromQuery] long personId, [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, accessUserId: personId.HasValue ? null : userId, offset: offset, limit: limit);
|
var events = await medicalDocsService.GetTimelineAsync(personId, offset, limit);
|
||||||
return Ok(events);
|
return Ok(events);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -845,7 +768,6 @@ 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);
|
||||||
@@ -860,7 +782,6 @@ 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);
|
||||||
@@ -872,18 +793,20 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("bills/summary")]
|
[HttpGet("bills/summary")]
|
||||||
public async Task<IActionResult> GetBillSummary([FromQuery] long? personId = null)
|
public async Task<IActionResult> GetBillSummary([FromQuery] long personId)
|
||||||
{
|
{
|
||||||
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 summary = await medicalDocsService.GetBillSummaryAsync(personId, accessUserId: personId.HasValue ? null : userId);
|
if (personId <= 0)
|
||||||
|
return BadRequest(new { error = "personId is required" });
|
||||||
|
|
||||||
|
var summary = await medicalDocsService.GetBillSummaryAsync(personId);
|
||||||
return Ok(summary);
|
return Ok(summary);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Auth helpers ---
|
// --- Auth helpers (same pattern as AdminApi) ---
|
||||||
|
|
||||||
private async Task<bool> HasMedicalAccess(long userId)
|
private async Task<bool> HasMedicalAccess(long userId)
|
||||||
{
|
{
|
||||||
@@ -892,18 +815,6 @@ 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;
|
||||||
@@ -925,7 +836,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(long PersonId, string Name, string? Specialty = null, string? Phone = null, string? Address = null, string? Notes = null);
|
public record CreateDoctorRequest(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);
|
||||||
@@ -940,4 +851,3 @@ 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);
|
|
||||||
|
|||||||
@@ -1,190 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
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);
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
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);
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
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);
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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);
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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');
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
-- 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);
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
-- 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,7 +10,6 @@
|
|||||||
<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" />
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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);
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
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,7 +3,6 @@ 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,9 +9,3 @@ 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,7 +4,6 @@ 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; }
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
@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>
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
@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>
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
@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>
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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,7 +21,6 @@
|
|||||||
</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,35 +93,6 @@
|
|||||||
</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">
|
||||||
@@ -149,31 +120,6 @@
|
|||||||
</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,10 +43,6 @@
|
|||||||
|
|
||||||
<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"
|
||||||
@@ -64,13 +60,10 @@
|
|||||||
<div class="invalid-feedback"></div>
|
<div class="invalid-feedback"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-options">
|
<div class="checkbox-wrapper">
|
||||||
<div class="checkbox-wrapper">
|
<input type="checkbox" id="rememberMe" name="rememberMe"
|
||||||
<input type="checkbox" id="rememberMe" name="rememberMe"
|
@(Model.RememberMe ? "checked" : "") />
|
||||||
@(Model.RememberMe ? "checked" : "") />
|
<label for="rememberMe">Remember me</label>
|
||||||
<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,20 +16,17 @@ 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, [FromQuery] string? reset)
|
public void OnGet([FromQuery] string? registered, [FromQuery] string? verified)
|
||||||
{
|
{
|
||||||
// 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(SafeReturnUrl() ?? "/Landing");
|
Response.Redirect("/Landing");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,12 +41,6 @@ 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()
|
||||||
@@ -93,9 +84,6 @@ public class LoginModel(AuthService authService) : PageModel
|
|||||||
Response.Cookies.Append("RememberMe", userInfo.Id.ToString(), cookieOptions);
|
Response.Cookies.Append("RememberMe", userInfo.Id.ToString(), cookieOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Redirect(SafeReturnUrl() ?? "/Landing");
|
return Redirect("/Landing");
|
||||||
}
|
}
|
||||||
|
|
||||||
private string? SafeReturnUrl() =>
|
|
||||||
!string.IsNullOrWhiteSpace(ReturnUrl) && Url.IsLocalUrl(ReturnUrl) ? ReturnUrl : null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
@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>
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
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,23 +285,6 @@
|
|||||||
</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,8 +7,133 @@
|
|||||||
|
|
||||||
@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" />
|
||||||
<link rel="stylesheet" href="~/css/profile.css" asp-append-version="true" />
|
<style>
|
||||||
<link rel="stylesheet" href="~/css/theme-customizer.css" asp-append-version="true" />
|
.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);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="profile-container">
|
<div class="profile-container">
|
||||||
@@ -51,14 +176,5 @@
|
|||||||
<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>
|
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
@page "/sso/authorize"
|
|
||||||
@model Media.JoshHeaps.Net.Pages.Sso.AuthorizeModel
|
|
||||||
@{
|
|
||||||
Layout = null;
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
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,11 +19,6 @@ 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,5 +1,3 @@
|
|||||||
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;
|
||||||
|
|
||||||
@@ -304,173 +302,4 @@ 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}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,258 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
@@ -1,293 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,973 +0,0 @@
|
|||||||
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}/LoginHelp?token={resetToken}";
|
var resetUrl = $"{appUrl}/ResetPassword?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(doc.PersonId, doctorName.Trim(), this);
|
var doctor = await medicalDocsService.FindOrCreateDoctorByNameAsync(doctorName.Trim());
|
||||||
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. If a document is a receipt for an individual prescription, label it as a prescription.";
|
Return ONLY the JSON object, no other text.";
|
||||||
|
|
||||||
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}.\nStderr: {Stderr}\nStdout: {Stdout}", process.ExitCode, stderr, stdout);
|
_logger.LogError("claude CLI exited with code {ExitCode}: {Stderr}", process.ExitCode, stderr);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -631,42 +631,6 @@ 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,16 +6,12 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment,
|
|||||||
{
|
{
|
||||||
// --- People ---
|
// --- People ---
|
||||||
|
|
||||||
public async Task<List<MedicalPerson>> GetPeopleAsync(long userId)
|
public async Task<List<MedicalPerson>> GetPeopleAsync()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return await db.ExecuteListReaderAsync(
|
return await db.ExecuteListReaderAsync(
|
||||||
@"SELECT mp.id, mp.name, mp.date_of_birth, mp.notes, mp.created_at, mp.updated_at
|
"SELECT id, name, date_of_birth, notes, created_at, updated_at FROM app.medical_people ORDER BY name",
|
||||||
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),
|
||||||
@@ -24,8 +20,7 @@ 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)
|
||||||
{
|
{
|
||||||
@@ -34,12 +29,12 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<MedicalPerson?> CreatePersonAsync(long userId, string name, DateTime? dateOfBirth = null, string? notes = null)
|
public async Task<MedicalPerson?> CreatePersonAsync(string name, DateTime? dateOfBirth = null, string? notes = null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var person = await db.ExecuteReaderAsync(
|
return 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",
|
||||||
@@ -53,15 +48,6 @@ 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)
|
||||||
{
|
{
|
||||||
@@ -70,110 +56,6 @@ 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)
|
||||||
@@ -298,14 +180,11 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
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)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var personFilter = personId.HasValue
|
var conditions = new List<string> { "person_id = @personId" };
|
||||||
? "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)");
|
||||||
@@ -343,8 +222,7 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment,
|
|||||||
|
|
||||||
return await db.ExecuteListReaderAsync(query, MapDocument, new
|
return await db.ExecuteListReaderAsync(query, MapDocument, new
|
||||||
{
|
{
|
||||||
personId = personId ?? 0L,
|
personId,
|
||||||
accessUserId = accessUserId ?? 0L,
|
|
||||||
search = !string.IsNullOrWhiteSpace(search) ? $"%{search}%" : (string?)null,
|
search = !string.IsNullOrWhiteSpace(search) ? $"%{search}%" : (string?)null,
|
||||||
classification,
|
classification,
|
||||||
documentType,
|
documentType,
|
||||||
@@ -365,20 +243,16 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<MedicalTag>> GetPersonTagsAsync(long? personId = null, long? accessUserId = null)
|
public async Task<List<MedicalTag>> GetPersonTagsAsync(long personId)
|
||||||
{
|
{
|
||||||
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 {personFilter}
|
WHERE d.person_id = @personId
|
||||||
ORDER BY t.name",
|
ORDER BY t.name",
|
||||||
reader => new MedicalTag
|
reader => new MedicalTag
|
||||||
{
|
{
|
||||||
@@ -386,7 +260,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 = personId ?? 0L, accessUserId = accessUserId ?? 0L });
|
new { personId });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -506,45 +380,39 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment,
|
|||||||
|
|
||||||
// --- Doctors ---
|
// --- Doctors ---
|
||||||
|
|
||||||
public async Task<List<MedicalDoctor>> GetDoctorsAsync(long? personId = null, long? accessUserId = null)
|
public async Task<List<MedicalDoctor>> GetDoctorsAsync()
|
||||||
{
|
{
|
||||||
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, specialty, phone, address, notes, created_at, updated_at FROM app.medical_doctors WHERE {personFilter} ORDER BY name",
|
"SELECT id, name, specialty, phone, address, notes, created_at, updated_at FROM app.medical_doctors ORDER BY name",
|
||||||
reader => new MedicalDoctor
|
reader => new MedicalDoctor
|
||||||
{
|
{
|
||||||
Id = reader.GetInt64(0),
|
Id = reader.GetInt64(0),
|
||||||
PersonId = reader.GetInt64(1),
|
Name = reader.GetString(1),
|
||||||
Name = reader.GetString(2),
|
Specialty = reader.IsDBNull(2) ? null : reader.GetString(2),
|
||||||
Specialty = reader.IsDBNull(3) ? null : reader.GetString(3),
|
Phone = reader.IsDBNull(3) ? null : reader.GetString(3),
|
||||||
Phone = reader.IsDBNull(4) ? null : reader.GetString(4),
|
Address = reader.IsDBNull(4) ? null : reader.GetString(4),
|
||||||
Address = reader.IsDBNull(5) ? null : reader.GetString(5),
|
Notes = reader.IsDBNull(5) ? null : reader.GetString(5),
|
||||||
Notes = reader.IsDBNull(6) ? null : reader.GetString(6),
|
CreatedAt = reader.GetDateTime(6),
|
||||||
CreatedAt = reader.GetDateTime(7),
|
UpdatedAt = 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 for person {PersonId}", personId);
|
logger.LogError(ex, "Failed to get doctors");
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<MedicalDoctor?> CreateDoctorAsync(long personId, string name, string? specialty = null, string? phone = null, string? address = null, string? notes = null)
|
public async Task<MedicalDoctor?> CreateDoctorAsync(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 (person_id, name, specialty, phone, address, notes, created_at, updated_at)
|
@"INSERT INTO app.medical_doctors (name, specialty, phone, address, notes, created_at, updated_at)
|
||||||
VALUES (@personId, @name, @specialty, @phone, @address, @notes, @createdAt, @updatedAt)
|
VALUES (@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
|
||||||
{
|
{
|
||||||
@@ -557,11 +425,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 { personId, name, specialty, phone, address, notes, createdAt = now, updatedAt = now });
|
new { name, specialty, phone, address, notes, createdAt = now, updatedAt = now });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Failed to create doctor for person {PersonId}", personId);
|
logger.LogError(ex, "Failed to create doctor");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -625,16 +493,12 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment,
|
|||||||
|
|
||||||
// --- Conditions ---
|
// --- Conditions ---
|
||||||
|
|
||||||
public async Task<List<MedicalCondition>> GetConditionsAsync(long? personId = null, long? accessUserId = null)
|
public async Task<List<MedicalCondition>> GetConditionsAsync(long personId)
|
||||||
{
|
{
|
||||||
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 {personFilter} ORDER BY name",
|
"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",
|
||||||
reader => new MedicalCondition
|
reader => new MedicalCondition
|
||||||
{
|
{
|
||||||
Id = reader.GetInt64(0),
|
Id = reader.GetInt64(0),
|
||||||
@@ -646,7 +510,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 = personId ?? 0L, accessUserId = accessUserId ?? 0L });
|
new { personId });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -717,21 +581,17 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment,
|
|||||||
|
|
||||||
// --- Prescriptions ---
|
// --- Prescriptions ---
|
||||||
|
|
||||||
public async Task<List<MedicalPrescription>> GetPrescriptionsAsync(long? personId = null, long? accessUserId = null)
|
public async Task<List<MedicalPrescription>> GetPrescriptionsAsync(long personId)
|
||||||
{
|
{
|
||||||
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 {personFilter}
|
WHERE p.person_id = @personId
|
||||||
ORDER BY p.medication_name",
|
ORDER BY p.medication_name",
|
||||||
reader => new MedicalPrescription
|
reader => new MedicalPrescription
|
||||||
{
|
{
|
||||||
@@ -751,7 +611,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 = personId ?? 0L, accessUserId = accessUserId ?? 0L });
|
new { personId });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -1013,13 +873,12 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<MedicalDoctor?> FindOrCreateDoctorByNameAsync(long personId, string doctorName, MedicalAiService aiService)
|
public async Task<MedicalDoctor?> FindOrCreateDoctorByNameAsync(string doctorName)
|
||||||
{
|
{
|
||||||
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 person_id = @personId AND LOWER(name) = LOWER(@name) LIMIT 1",
|
"SELECT id, name, specialty, phone, address, notes, created_at, updated_at FROM app.medical_doctors WHERE LOWER(name) = LOWER(@name) LIMIT 1",
|
||||||
reader => new MedicalDoctor
|
reader => new MedicalDoctor
|
||||||
{
|
{
|
||||||
Id = reader.GetInt64(0),
|
Id = reader.GetInt64(0),
|
||||||
@@ -1031,29 +890,15 @@ 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, name = doctorName });
|
new { name = doctorName });
|
||||||
|
|
||||||
if (existing != null) return existing;
|
if (existing != null) return existing;
|
||||||
|
|
||||||
// Step 2: AI fuzzy match
|
return await CreateDoctorAsync(doctorName);
|
||||||
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}\" for person {PersonId}", doctorName, personId);
|
logger.LogError(ex, "Failed to find or create doctor by name \"{DoctorName}\"", doctorName);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1070,7 +915,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(personId, docName.Trim(), aiService);
|
var doctor = await FindOrCreateDoctorByNameAsync(docName.Trim());
|
||||||
doctorId = doctor?.Id;
|
doctorId = doctor?.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1236,22 +1081,18 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment,
|
|||||||
|
|
||||||
// --- Billing Providers ---
|
// --- Billing Providers ---
|
||||||
|
|
||||||
public async Task<List<MedicalBillingProvider>> GetProvidersAsync(long? personId = null, long? accessUserId = null)
|
public async Task<List<MedicalBillingProvider>> GetProvidersAsync(long personId)
|
||||||
{
|
{
|
||||||
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 {personFilter}
|
WHERE p.person_id = @personId
|
||||||
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
|
||||||
@@ -1266,7 +1107,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 = personId ?? 0L, accessUserId = accessUserId ?? 0L });
|
new { personId });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -1522,13 +1363,10 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment,
|
|||||||
|
|
||||||
// --- Bills ---
|
// --- Bills ---
|
||||||
|
|
||||||
public async Task<List<MedicalBill>> GetBillsAsync(long? personId = null, long? providerId = null, long? accessUserId = null)
|
public async Task<List<MedicalBill>> GetBillsAsync(long personId, long? providerId = 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,
|
||||||
@@ -1541,10 +1379,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 {personFilter} {providerFilter}
|
WHERE b.person_id = @personId {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 = personId ?? 0L, accessUserId = accessUserId ?? 0L, providerId });
|
return await db.ExecuteListReaderAsync(query, MapBill, new { personId, providerId });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -1664,34 +1502,26 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment,
|
|||||||
|
|
||||||
// --- Bill Summary ---
|
// --- Bill Summary ---
|
||||||
|
|
||||||
public async Task<BillSummary> GetBillSummaryAsync(long? personId = null, long? accessUserId = null)
|
public async Task<BillSummary> GetBillSummaryAsync(long personId)
|
||||||
{
|
{
|
||||||
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 {providerPersonFilter}), 0)
|
WHERE prov.person_id = @personId), 0)
|
||||||
FROM app.medical_bills b
|
FROM app.medical_bills b
|
||||||
WHERE {personFilter}",
|
WHERE b.person_id = @personId",
|
||||||
reader => new { Charged = reader.GetDecimal(0), TotalPaid = reader.GetDecimal(1) },
|
reader => new { Charged = reader.GetDecimal(0), TotalPaid = reader.GetDecimal(1) },
|
||||||
filterParams);
|
new { personId });
|
||||||
|
|
||||||
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 {personFilter}
|
WHERE b.person_id = @personId
|
||||||
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
|
||||||
@@ -1700,15 +1530,15 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment,
|
|||||||
Total = reader.GetDecimal(1),
|
Total = reader.GetDecimal(1),
|
||||||
Count = reader.GetInt32(2)
|
Count = reader.GetInt32(2)
|
||||||
},
|
},
|
||||||
filterParams);
|
new { personId });
|
||||||
|
|
||||||
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 {personFilter}
|
WHERE b.person_id = @personId
|
||||||
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
|
||||||
@@ -1717,7 +1547,7 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment,
|
|||||||
Total = reader.GetDecimal(1),
|
Total = reader.GetDecimal(1),
|
||||||
Count = reader.GetInt32(2)
|
Count = reader.GetInt32(2)
|
||||||
},
|
},
|
||||||
filterParams);
|
new { personId });
|
||||||
|
|
||||||
var charged = totals?.Charged ?? 0;
|
var charged = totals?.Charged ?? 0;
|
||||||
var totalPaid = totals?.TotalPaid ?? 0;
|
var totalPaid = totals?.TotalPaid ?? 0;
|
||||||
@@ -2337,34 +2167,30 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment,
|
|||||||
|
|
||||||
// --- Timeline ---
|
// --- Timeline ---
|
||||||
|
|
||||||
public async Task<List<TimelineEvent>> GetTimelineAsync(long? personId = null, long? accessUserId = null, int offset = 0, int limit = 100)
|
public async Task<List<TimelineEvent>> GetTimelineAsync(long personId, 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, person_id, label, detail, sub_type, event_date, doctor_id, created_at
|
@"SELECT event_type, id, label, detail, sub_type, event_date, doctor_id, created_at
|
||||||
FROM (
|
FROM (
|
||||||
SELECT 'document' AS event_type, d.id, d.person_id, COALESCE(d.title, d.file_name) AS label, d.description AS detail,
|
SELECT 'document' AS event_type, d.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 {personFilter}
|
FROM app.medical_documents d WHERE d.person_id = @personId
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'condition', c.id, c.person_id, c.name, c.notes,
|
SELECT 'condition', c.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 {personFilter}
|
FROM app.medical_conditions c WHERE c.person_id = @personId
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'prescription', p.id, p.person_id, p.medication_name, CONCAT_WS(' - ', p.dosage, p.frequency),
|
SELECT 'prescription', p.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 {personFilter}
|
FROM app.medical_prescriptions p WHERE p.person_id = @personId
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'bill', b.id, b.person_id, b.summary, bp.name,
|
SELECT 'bill', b.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 {personFilter}
|
WHERE b.person_id = @personId
|
||||||
) 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",
|
||||||
@@ -2372,15 +2198,14 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment,
|
|||||||
{
|
{
|
||||||
EventType = reader.GetString(0),
|
EventType = reader.GetString(0),
|
||||||
Id = reader.GetInt64(1),
|
Id = reader.GetInt64(1),
|
||||||
PersonId = reader.GetInt64(2),
|
Label = reader.IsDBNull(2) ? null : reader.GetString(2),
|
||||||
Label = reader.IsDBNull(3) ? null : reader.GetString(3),
|
Detail = reader.IsDBNull(3) ? null : reader.GetString(3),
|
||||||
Detail = reader.IsDBNull(4) ? null : reader.GetString(4),
|
SubType = reader.IsDBNull(4) ? null : reader.GetString(4),
|
||||||
SubType = reader.IsDBNull(5) ? null : reader.GetString(5),
|
EventDate = reader.IsDBNull(5) ? null : reader.GetDateTime(5),
|
||||||
EventDate = reader.IsDBNull(6) ? null : reader.GetDateTime(6),
|
DoctorId = reader.IsDBNull(6) ? null : reader.GetInt64(6),
|
||||||
DoctorId = reader.IsDBNull(7) ? null : reader.GetInt64(7),
|
CreatedAt = reader.GetDateTime(7)
|
||||||
CreatedAt = reader.GetDateTime(8)
|
|
||||||
},
|
},
|
||||||
new { personId = personId ?? 0L, accessUserId = accessUserId ?? 0L, limit, offset });
|
new { personId, limit, offset });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
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,21 +20,6 @@
|
|||||||
"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,29 +152,6 @@
|
|||||||
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;
|
||||||
|
|||||||
@@ -1,281 +0,0 @@
|
|||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,250 +0,0 @@
|
|||||||
/* 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,610 +0,0 @@
|
|||||||
/* 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,39 +323,6 @@
|
|||||||
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 */
|
||||||
/* ======================== */
|
/* ======================== */
|
||||||
@@ -1527,26 +1494,6 @@
|
|||||||
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) */
|
||||||
/* ======================== */
|
/* ======================== */
|
||||||
|
|||||||
@@ -1,140 +0,0 @@
|
|||||||
.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);
|
|
||||||
}
|
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
#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,145 +336,9 @@ 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();
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,195 +0,0 @@
|
|||||||
(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();
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
// 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' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,243 +0,0 @@
|
|||||||
// 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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,536 +0,0 @@
|
|||||||
// 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
// 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)));
|
|
||||||
}
|
|
||||||
@@ -1,380 +0,0 @@
|
|||||||
// 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 };
|
|
||||||
@@ -1,474 +0,0 @@
|
|||||||
// 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();
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
// 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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
// 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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,428 +0,0 @@
|
|||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
// 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);
|
|
||||||
}
|
|
||||||
@@ -1,307 +0,0 @@
|
|||||||
// 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 };
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
// 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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
// 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;
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
// 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 };
|
|
||||||
@@ -1,536 +0,0 @@
|
|||||||
// 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();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
// 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;
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 };
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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);
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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';
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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';
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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]);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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';
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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() {},
|
|
||||||
});
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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' });
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 };
|
|
||||||
}
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 };
|
|
||||||
@@ -1,661 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 };
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,428 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,353 +0,0 @@
|
|||||||
// 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;
|
|
||||||
}
|
|
||||||
@@ -1,729 +0,0 @@
|
|||||||
// 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 };
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
// 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 [];
|
|
||||||
}
|
|
||||||
@@ -1,477 +0,0 @@
|
|||||||
// 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,10 +4,11 @@
|
|||||||
// --- Providers ---
|
// --- Providers ---
|
||||||
|
|
||||||
app.loadBills = async function () {
|
app.loadBills = async function () {
|
||||||
const personParam = state.selectedPersonId ? `?personId=${state.selectedPersonId}` : '';
|
if (!state.selectedPersonId) return;
|
||||||
|
|
||||||
const [providersRes, summaryRes] = await Promise.all([
|
const [providersRes, summaryRes] = await Promise.all([
|
||||||
fetch(`${app.API}/providers${personParam}`),
|
fetch(`${app.API}/providers?personId=${state.selectedPersonId}`),
|
||||||
fetch(`${app.API}/bills/summary${personParam}`)
|
fetch(`${app.API}/bills/summary?personId=${state.selectedPersonId}`)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (providersRes.ok) {
|
if (providersRes.ok) {
|
||||||
@@ -77,8 +78,7 @@
|
|||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
// Fetch unassigned bills
|
// Fetch unassigned bills
|
||||||
const unassignedBillParam = state.selectedPersonId ? `?personId=${state.selectedPersonId}` : '';
|
const unassignedRes = await fetch(`${app.API}/bills?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.personBadge(p.personId)}${app.escapeHtml(p.name)}
|
${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,9 +164,8 @@
|
|||||||
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?${billsParam}providerId=${providerId}`),
|
fetch(`${app.API}/bills?personId=${state.selectedPersonId}&providerId=${providerId}`),
|
||||||
fetch(`${app.API}/providers/${providerId}/payments`)
|
fetch(`${app.API}/providers/${providerId}/payments`)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -193,8 +192,6 @@
|
|||||||
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));
|
||||||
@@ -216,7 +213,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" ${showForms}>
|
<div class="pickup-form">
|
||||||
<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" />
|
||||||
@@ -224,8 +221,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" ${showForms}></div>
|
<div class="section-divider"></div>
|
||||||
<div class="pickup-form" ${showForms}>
|
<div class="pickup-form">
|
||||||
<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>
|
||||||
@@ -254,7 +251,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" ${showForms}>
|
<div class="pickup-form">
|
||||||
<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" />
|
||||||
@@ -268,7 +265,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" ${showForms}>
|
<div class="pickup-form">
|
||||||
<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" />
|
||||||
@@ -284,8 +281,7 @@
|
|||||||
const section = document.getElementById('provider-details-unassigned');
|
const section = document.getElementById('provider-details-unassigned');
|
||||||
if (!section) return;
|
if (!section) return;
|
||||||
|
|
||||||
const unassignedParam = state.selectedPersonId ? `?personId=${state.selectedPersonId}` : '';
|
const res = await fetch(`${app.API}/bills?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);
|
||||||
@@ -535,8 +531,7 @@
|
|||||||
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 assignParam = state.selectedPersonId ? `?personId=${state.selectedPersonId}` : '';
|
const getRes = await fetch(`${app.API}/bills?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);
|
||||||
@@ -566,10 +561,9 @@
|
|||||||
|
|
||||||
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${refreshParam}`),
|
fetch(`${app.API}/providers?personId=${state.selectedPersonId}`),
|
||||||
fetch(`${app.API}/bills/summary${refreshParam}`)
|
fetch(`${app.API}/bills/summary?personId=${state.selectedPersonId}`)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (providersRes.ok) {
|
if (providersRes.ok) {
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
const { state } = app;
|
const { state } = app;
|
||||||
|
|
||||||
app.loadConditions = async function () {
|
app.loadConditions = async function () {
|
||||||
const personParam = state.selectedPersonId ? `?personId=${state.selectedPersonId}` : '';
|
if (!state.selectedPersonId) return;
|
||||||
const res = await fetch(`${app.API}/conditions${personParam}`);
|
const res = await fetch(`${app.API}/conditions?personId=${state.selectedPersonId}`);
|
||||||
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.personBadge(c.personId)}${app.escapeHtml(c.name)} ${statusBadge}</div>
|
<div class="condition-name">${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,8 +64,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
app.toggleConditionActive = async function (id, isActive) {
|
app.toggleConditionActive = async function (id, isActive) {
|
||||||
const toggleParam = state.selectedPersonId ? `?personId=${state.selectedPersonId}` : '';
|
const res = await fetch(`${app.API}/conditions?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,8 +2,7 @@
|
|||||||
const { state } = app;
|
const { state } = app;
|
||||||
|
|
||||||
app.loadDoctors = async function () {
|
app.loadDoctors = async function () {
|
||||||
const personParam = state.selectedPersonId ? `?personId=${state.selectedPersonId}` : '';
|
const res = await fetch(`${app.API}/doctors`);
|
||||||
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();
|
||||||
@@ -27,7 +26,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.personBadge(doc.personId)}${app.escapeHtml(doc.name)}</div>
|
<div class="doctor-name">${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>
|
||||||
@@ -69,7 +68,6 @@
|
|||||||
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,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user