Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
2126c3284This file is pinned to the selected release. Download the original to open it in your design tool.
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>akcli live — schematic watch</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%23d08a3d'/%3E%3Ccircle cx='8' cy='8' r='3.6' fill='none' stroke='%231a1408' stroke-width='1.6'/%3E%3Ccircle cx='8' cy='8' r='1.4' fill='%231a1408'/%3E%3C/svg%3E">
<style>
/* ============================ theme ================================== */
:root{
--mono: ui-monospace,"SF Mono","JetBrains Mono","Cascadia Code","Fira Code",Menlo,Consolas,monospace;
--disp: "Avenir Next Condensed","Arial Narrow","Helvetica Neue",Arial,sans-serif;
}
:root[data-theme="dark"]{
--bg:#14161a; --bg2:#181b20; --panel:#1d2127; --panel2:#171a1f; --inset:#101317;
--border:#2b3038; --border2:#3a414c;
--text:#ced3da; --muted:#8b929d; --faint:#5f6671;
--brand:#d08a3d; --brand2:#e8ab61; --oncopper:#1a1408;
--data:#66c8de; --live:#58c48e; --err:#e5665f; --warn:#d9a13c;
--sel:rgba(208,138,61,.13); --focus:rgba(102,200,222,.45);
--grid:rgba(102,200,222,.045); --grid2:rgba(102,200,222,.028);
--shadow:0 6px 24px rgba(0,0,0,.35); --paper:#fff; --paper-ink:#14161a;
}
:root[data-theme="light"]{
--bg:#e9e5db; --bg2:#efece3; --panel:#faf8f2; --panel2:#f0ede4; --inset:#fdfcf9;
--border:#d3ccbb; --border2:#bcb4a0;
--text:#2b2e33; --muted:#6c7076; --faint:#9a9c9e;
--brand:#a96617; --brand2:#8a5310; --oncopper:#fff8ec;
--data:#0f6f88; --live:#1e8a57; --err:#bf3934; --warn:#8f6408;
--sel:rgba(169,102,23,.10); --focus:rgba(15,111,136,.35);
--grid:rgba(15,111,136,.055); --grid2:rgba(15,111,136,.03);
--shadow:0 4px 18px rgba(60,50,30,.12); --paper:#fff; --paper-ink:#14161a;
}
/* ============================ base =================================== */
*{box-sizing:border-box; margin:0}
[hidden]{display:none !important}
html,body{height:100%}
body{background:var(--bg); color:var(--text); font:13px/1.5 var(--mono); overflow:hidden}
button{font:inherit; color:inherit; cursor:pointer}
:focus-visible{outline:2px solid var(--focus); outline-offset:1px; border-radius:4px}
@media (prefers-reduced-motion:reduce){ *,*::before,*::after{animation:none !important; transition:none !important} }
.wrap{height:100vh; display:grid; gap:12px; padding:12px;
grid-template-columns:1fr 320px; grid-template-rows:auto 1fr;
grid-template-areas:"head head" "stage side"; max-width:1720px; margin:0 auto}
@media (max-width:940px){
.wrap{grid-template-columns:1fr; grid-template-rows:auto 1fr auto;
grid-template-areas:"head" "stage" "side"; overflow-y:auto; height:auto}
body{overflow:auto}
}
/* ============ header — shared bench chrome (hub/calc/live) ============ */
header{grid-area:head; display:flex; align-items:center; gap:10px; flex-wrap:wrap}
#mark{font-family:var(--disp); font-size:15px; font-weight:600; letter-spacing:.1em;
text-transform:uppercase; color:var(--brand2); text-decoration:none; white-space:nowrap}
#mark:hover{color:var(--brand)}
.tabs{display:flex; border:1px solid var(--border); border-radius:8px; overflow:hidden;
background:var(--panel)}
.tabs a{display:flex; align-items:center; padding:3px 13px; color:var(--muted);
text-decoration:none; font-family:var(--disp); font-size:11.5px; font-weight:600;
letter-spacing:.12em; text-transform:uppercase; border-right:1px solid var(--border)}
.tabs a:last-child{border-right:none}
.tabs a.on{background:var(--sel); color:var(--brand2)}
.tabs a:hover:not(.on){color:var(--text)}
.dot{width:9px; height:9px; border-radius:50%; background:var(--live); animation:pulse 2.2s infinite}
.dot.stale{background:var(--err); animation:none}
@keyframes pulse{50%{opacity:.3}}
.chip{background:var(--panel); border:1px solid var(--border); border-radius:99px;
padding:2px 11px; font-size:11.5px; color:var(--muted); white-space:nowrap}
.chip b{color:var(--text); font-weight:600}
.chip.erc-ok b{color:var(--live)} .chip.erc-bad b{color:var(--err)}
.chip.errchip{border-color:var(--err); color:var(--err); max-width:420px;
overflow:hidden; text-overflow:ellipsis}
.chip.errchip b{color:var(--err)}
a.chip{text-decoration:none; cursor:pointer}
a.chip:hover{border-color:var(--brand); color:var(--brand2)}
.spacer{flex:1}
button.ui{background:var(--panel); color:var(--text); border:1px solid var(--border);
border-radius:7px; padding:4px 10px; font-size:12px}
button.ui:hover{border-color:var(--border2)}
button.ui.on{border-color:var(--brand); color:var(--brand2)}
button.ui.primary{border-color:var(--live); color:var(--live); font-weight:700}
button.ui:disabled{opacity:.4; cursor:default}
.kbd{display:inline-block; border:1px solid var(--border2); border-bottom-width:2px;
border-radius:4px; padding:0 5px; font-size:10px; color:var(--muted); background:var(--panel)}
/* ============================ stage ================================== */
.stage{grid-area:stage; background:var(--panel); border:1px solid var(--border);
border-radius:12px; display:flex; flex-direction:column; overflow:hidden;
min-height:420px; box-shadow:var(--shadow)}
.toolbar{display:flex; gap:6px; align-items:center; padding:8px 10px;
border-bottom:1px solid var(--border); flex-wrap:wrap}
.toolbar .sep{width:1px; height:18px; background:var(--border); margin:0 3px}
.toolbar .zoomlvl{font-size:11.5px; color:var(--muted); min-width:42px; text-align:center}
#sheettabs{display:flex; gap:6px; padding:6px 10px; border-bottom:1px solid var(--border);
flex-wrap:wrap}
#sheettabs button{font-size:11px; padding:2px 10px}
.canvas{flex:1; overflow:hidden; position:relative; cursor:grab; background:
linear-gradient(var(--grid2) 1px, transparent 1px) 0 0/100% 96px,
linear-gradient(90deg,var(--grid2) 1px, transparent 1px) 0 0/96px 100%,
linear-gradient(var(--grid) 1px, transparent 1px) 0 0/100% 24px,
linear-gradient(90deg,var(--grid) 1px, transparent 1px) 0 0/24px 100%,
var(--bg2)}
.canvas.dragging{cursor:grabbing}
.frame{position:absolute; background:var(--paper); border-radius:6px; padding:10px;
box-shadow:0 2px 18px rgba(0,0,0,.28); transform-origin:0 0}
.frame.ink{background:var(--paper-ink)}
.frame.ink #art svg{filter:invert(.93) hue-rotate(180deg)}
#art{position:relative}
#art svg{display:block}
#ghost{position:absolute; inset:10px; pointer-events:none; opacity:.4;
filter:brightness(0) saturate(100%) invert(32%) sepia(84%) saturate(2600%)
hue-rotate(345deg) brightness(1.1)}
#ghost svg{display:block}
#ovl,#fovl{position:absolute; inset:10px 10px auto 10px; pointer-events:none; overflow:visible}
#ovl .mk,#fovl .mk{pointer-events:all; cursor:help}
#ovl .ring,#fovl .ring{fill:none; stroke-width:1.6; vector-effect:non-scaling-stroke;
animation:ping 2.2s ease-out infinite; transform-box:fill-box; transform-origin:center}
@keyframes ping{0%{transform:scale(.55); opacity:1} 80%{transform:scale(1.5); opacity:0} 100%{opacity:0}}
#ovl .mk.flash .ring,#fovl .mk.flash .ring{animation-duration:.55s}
/* lint markers use a square outline + the data hue so they never read as ERC */
#fovl .ring{stroke-dasharray:2.4 1.8}
.empty{position:absolute; inset:0; display:flex; flex-direction:column; gap:8px;
align-items:center; justify-content:center; color:var(--muted); text-align:center;
font-size:12.5px; padding:20px}
.empty b{color:var(--text)}
.empty code{background:var(--panel2); border:1px solid var(--border); padding:0 6px;
border-radius:4px}
/* ============================ sidebar ================================ */
aside{grid-area:side; background:var(--panel); border:1px solid var(--border);
border-radius:12px; display:flex; flex-direction:column; overflow:hidden;
min-height:200px; box-shadow:var(--shadow)}
aside h2{font-family:var(--disp); font-size:11px; color:var(--muted); text-transform:uppercase;
letter-spacing:.16em; padding:12px 14px 6px; display:flex; align-items:center; gap:8px}
#spark{margin-left:auto}
.steps{overflow-y:auto; padding:0 8px 10px; flex:1; min-height:80px; scrollbar-width:thin}
/* steps are real <button>s (keyboard + AT reachable) styled as list rows */
.step{display:block; width:100%; text-align:left; background:none;
padding:8px 10px; border-radius:9px; cursor:pointer;
border:1px solid transparent; margin-bottom:4px}
.step:hover{border-color:var(--border)}
.step.active{border-color:var(--brand); background:var(--sel)}
.step .row{display:flex; align-items:baseline; gap:8px}
.step .n{font-weight:700}
.step .t{color:var(--faint); font-size:11px}
.live-tag{font-size:9.5px; font-weight:700; color:var(--live); border:1px solid var(--live);
border-radius:4px; padding:0 4px; letter-spacing:.05em}
.badge{font-size:10.5px; border-radius:5px; padding:0 6px; font-weight:700; margin-left:auto}
.badge.ok{color:var(--live); background:color-mix(in srgb, var(--live) 13%, transparent)}
.badge.err{color:var(--err); background:color-mix(in srgb, var(--err) 13%, transparent)}
.badge.warn{color:var(--warn); background:color-mix(in srgb, var(--warn) 15%, transparent)}
.badge.mute{color:var(--muted); background:var(--panel2)}
.step .note{font-size:12px; margin-top:2px; color:var(--text)}
.step .meta{font-size:10.5px; color:var(--muted); margin-top:1px}
.step .meta .d{color:var(--data)}
#ercpanel{border-top:1px solid var(--border); max-height:34%; display:flex;
flex-direction:column; min-height:0}
#ercpanel h2{cursor:pointer; user-select:none; padding-bottom:8px}
#ercpanel h2 .cnt{margin-left:auto; font-family:var(--mono); letter-spacing:0}
#erclist{overflow-y:auto; padding:0 8px 10px; scrollbar-width:thin}
.viol{display:flex; gap:8px; align-items:baseline; padding:5px 8px; border-radius:7px;
cursor:pointer; font-size:11.5px; background:none; border:none; width:100%; text-align:left}
.viol:hover{background:var(--sel)}
.viol .sv{flex:none; font-weight:700; width:14px}
.viol .sv.e{color:var(--err)} .viol .sv.w{color:var(--warn)}
.viol .tx{color:var(--text); overflow:hidden; text-overflow:ellipsis; white-space:nowrap}
.viol .it{color:var(--faint); overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
flex:none; max-width:40%}
.newtag{flex:none; font-size:9px; font-weight:700; color:var(--err); border:1px solid var(--err);
border-radius:4px; padding:0 4px; letter-spacing:.05em}
.erc-resolved{color:var(--live)}
#notebar{display:flex; gap:6px; padding:8px 10px; border-top:1px solid var(--border)}
#notebar input{flex:1; min-width:0; padding:5px 9px; background:var(--inset);
border:1px solid var(--border); color:var(--text); border-radius:7px;
font:inherit; font-size:12px}
#notebar input::placeholder{color:var(--faint)}
#clearbtn{margin-left:6px; font-size:10px; padding:0 6px; border-radius:5px;
background:none; border:1px solid transparent; color:var(--faint)}
#clearbtn:hover{border-color:var(--err); color:var(--err)}
.hint{border-top:1px solid var(--border); padding:8px 14px; font-size:10.5px; color:var(--faint)}
.hint code{background:var(--panel2); padding:0 4px; border-radius:4px}
/* ============================ overlays / toast ======================= */
.overlay{position:fixed; inset:0; background:rgba(8,10,12,.55); display:flex;
justify-content:center; align-items:flex-start; padding-top:14vh; z-index:50;
backdrop-filter:blur(2px)}
#helpbox{width:min(560px,90vw); background:var(--panel); border:1px solid var(--border2);
border-radius:12px; box-shadow:var(--shadow); padding:20px 24px}
#helpbox h3{margin:0 0 12px; font-family:var(--disp); letter-spacing:.14em;
text-transform:uppercase; color:var(--brand2); font-size:14px}
#helpbox table{width:100%; border-collapse:collapse; font-size:12.5px}
#helpbox td{padding:4px 6px; border-bottom:1px solid var(--border)}
#helpbox td:first-child{width:150px; color:var(--muted)}
#bombox{width:min(860px,94vw); max-height:76vh; background:var(--panel);
border:1px solid var(--border2); border-radius:12px; box-shadow:var(--shadow);
padding:18px 22px; display:flex; flex-direction:column}
#bombox h3{margin:0 0 10px; font-family:var(--disp); letter-spacing:.14em;
text-transform:uppercase; color:var(--brand2); font-size:14px;
display:flex; align-items:baseline; gap:12px}
#bombox h3 .sum{font-family:var(--mono); font-size:11.5px; color:var(--muted);
text-transform:none; letter-spacing:0}
#bomtable{overflow:auto; flex:1; min-height:60px}
#bomtable table{border-collapse:collapse; width:100%; font-size:12px}
#bomtable th,#bomtable td{border:1px solid var(--border); padding:4px 9px; text-align:left}
#bomtable th{background:var(--panel2); color:var(--muted); font-family:var(--disp);
font-size:10.5px; letter-spacing:.12em; text-transform:uppercase;
position:sticky; top:0}
#bomtable .dslink{text-decoration:none; margin-left:5px; opacity:.85}
#bomtable .dslink:hover{opacity:1}
#bomtable .st-ok{color:var(--live)} #bomtable .st-bad{color:var(--err)}
#bomtable .st-warn{color:var(--warn)} #bomtable .st-mute{color:var(--faint)}
#bomfoot{display:flex; gap:10px; align-items:center; margin-top:12px}
#bomfoot .hintline{font-size:10.5px; color:var(--faint); flex:1}
#toast{position:fixed; bottom:26px; left:50%; transform:translateX(-50%);
background:var(--panel); color:var(--text); border:1px solid var(--brand);
border-radius:8px; padding:7px 18px; font-size:12.5px; box-shadow:var(--shadow);
z-index:60; opacity:0; pointer-events:none; transition:opacity .18s}
#toast.show{opacity:1}
</style>
</head>
<body>
<div class="wrap">
<header>
<a id="mark" href="/" title="hub (h)">⌂ akcli</a>
<nav class="tabs" aria-label="pages">
<a href="/" title="hub (h)">⌂</a>
<a href="/calc" title="calculator bench">= calc</a>
<a href="/live" class="on" title="live schematic timeline">◎ live</a>
</nav>
<span class="dot" id="dot" title="watcher connection"></span>
<span class="chip">watching <b id="file">…</b></span>
<span class="chip">step <b id="stepno">–</b></span>
<span class="chip" id="counts" hidden><b id="parts">0</b> parts · <b id="nets">0</b> nets</span>
<span class="chip" id="ercchip" hidden>ERC <b id="erctot">–</b></span>
<span class="chip errchip" id="werr" hidden role="alert">⚠ watcher <b id="werrmsg"></b></span>
<span class="spacer"></span>
<button id="follow" class="ui primary" hidden>▶ back to live</button>
<button id="themebtn" class="ui" title="toggle dark / light">◐</button>
<button id="helpbtn" class="ui" title="keyboard shortcuts (?)">?</button>
</header>
<div class="stage">
<div class="toolbar">
<button class="ui" id="zout" title="zoom out">−</button>
<span class="zoomlvl" id="zlvl">fit</span>
<button class="ui" id="zin" title="zoom in">+</button>
<button class="ui" id="zfit" title="fit to window (F)">⤢ fit</button>
<span class="sep"></span>
<button class="ui on" id="crop" title="crop to drawn content (C)">crop</button>
<button class="ui" id="diff" title="ghost the previous step under this one (D)">diff</button>
<button class="ui" id="ercmk" title="overlay ERC violation markers (E)">erc</button>
<button class="ui" id="lintmk" title="overlay akcli lint findings — nets/geom/layout (G)">lint</button>
<button class="ui" id="ink" title="dark drafting paper (I)">ink</button>
<span class="sep"></span>
<button class="ui" id="prev" title="previous step (←)">←</button>
<button class="ui" id="next" title="next step (→)">→</button>
<button class="ui" id="play" title="replay the timeline (space)">▶ replay</button>
<span class="spacer"></span>
<button class="ui" id="bombtn" title="bill of materials (b)">bom</button>
<button class="ui" id="opensvg" title="open the raw SVG">svg</button>
<button class="ui" id="dlpng" title="rasterize the current view to PNG">⤓ png</button>
</div>
<div id="sheettabs" hidden></div>
<div class="canvas" id="canvas">
<div class="frame" id="frame" hidden>
<div id="ghost" hidden></div>
<div id="art"></div>
<svg id="ovl" hidden></svg>
<svg id="fovl" hidden></svg>
</div>
<div class="empty" id="empty">
<div><b>Waiting for the first draw…</b></div>
<div>every save of the watched schematic becomes a timeline step</div>
<div>drive it with <code>akcli draw --apply</code> · annotate the next step via <code>note.txt</code></div>
</div>
</div>
</div>
<aside>
<h2>Timeline <button id="clearbtn" title="clear the whole timeline">✕ clear</button>
<svg id="spark" width="96" height="22" aria-label="parts trend"></svg></h2>
<div class="steps" id="list" aria-label="timeline steps"></div>
<div id="ercpanel" hidden>
<h2 id="erchead">ERC <span class="cnt" id="erccnt"></span></h2>
<div id="erclist"></div>
</div>
<div id="notebar" hidden>
<input id="noteinp" placeholder="annotate the next step… (⏎ to send)" maxlength="200">
<button class="ui" id="notebtn" title="the note appears on the next timeline step">✎</button>
</div>
<div class="hint">
newest step follows automatically · <span class="kbd">←</span><span class="kbd">→</span> browse ·
<span class="kbd">L</span> live · <span class="kbd">?</span> keys ·
KiCad: <code>File → Revert</code> reloads the sheet
</div>
</aside>
</div>
<div class="overlay" id="bomov" hidden>
<div id="bombox" role="dialog" aria-modal="true" aria-label="bill of materials" tabindex="-1">
<h3>BOM <span class="sum" id="bomsum"></span></h3>
<div id="bomtable">loading…</div>
<div id="bomfoot">
<button class="ui primary" id="bomcheck"
title="queries the JLCPCB catalog — the only networked action">
⇅ check purchasability</button>
<span class="hintline">offline by default — the stock/price check is the
one action that touches the network (jlcsearch), and only when clicked.
Part ids come from LCSC / MPN component parameters.</span>
</div>
</div>
</div>
<div class="overlay" id="helpov" hidden>
<div id="helpbox" role="dialog" aria-modal="true" aria-label="keyboard shortcuts" tabindex="-1">
<h3>Keyboard</h3>
<table>
<tr><td><span class="kbd">←</span> <span class="kbd">→</span></td><td>previous / next step</td></tr>
<tr><td><span class="kbd">L</span></td><td>back to live (follow newest)</td></tr>
<tr><td><span class="kbd">H</span></td><td>back to the hub</td></tr>
<tr><td><span class="kbd">space</span></td><td>replay the timeline</td></tr>
<tr><td><span class="kbd">F</span></td><td>fit to window</td></tr>
<tr><td><span class="kbd">C</span></td><td>crop to drawn content</td></tr>
<tr><td><span class="kbd">D</span></td><td>diff — previous step ghosted in red</td></tr>
<tr><td><span class="kbd">E</span></td><td>ERC markers on the schematic</td></tr>
<tr><td><span class="kbd">G</span></td><td>akcli lint markers (nets / geom / layout)</td></tr>
<tr><td><span class="kbd">I</span></td><td>ink — dark drafting paper</td></tr>
<tr><td><span class="kbd">B</span></td><td>BOM panel (purchasability check on demand)</td></tr>
<tr><td><span class="kbd">+</span> <span class="kbd">−</span> <span class="kbd">0</span></td><td>zoom in / out / fit</td></tr>
<tr><td><span class="kbd">?</span></td><td>this help</td></tr>
</table>
<p style="margin-top:12px; font-size:11px; color:var(--faint)">
Scroll to zoom at the pointer · drag to pan · double-click toggles zoom ·
click an ERC finding to jump to it on the sheet.</p>
</div>
</div>
<div id="toast" role="status" aria-live="polite"></div>
<script>
"use strict";
const $ = id => document.getElementById(id);
const BASE_W = 900, PAD = 10; // frame layout width at scale 1, frame padding
let steps = [], version = -1, viewing = -1, following = true, lastOk = 0;
let cropped = true, diffOn = false, ercOn = false, sheetIdx = 0;
let lintOn = false, lintFindings = null, lintErr = null;
let pageVB = null, cropVB = null, svgEl = null;
let playT = null;
const svgCache = new Map(); // svg url -> text (for sheets + diff ghosts)
const sheetsOf = st => (st && (st.sheets || [st.svg]).filter(Boolean)) || [];
/* ---------------- theme / prefs ---------------- */
function store(k, d){ try { return JSON.parse(localStorage.getItem(k)) ?? d; } catch(e){ return d; } }
function save(k, v){ try { localStorage.setItem(k, JSON.stringify(v)); } catch(e){} }
function applyTheme(t){ document.documentElement.dataset.theme = t; save("akcli-theme", t); }
applyTheme(store("akcli-theme", null) ||
(matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"));
$("themebtn").onclick = () =>
applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark");
let inkOn = store("akcli-live-ink", null);
if (inkOn === null) inkOn = document.documentElement.dataset.theme === "dark";
let toastT = null;
function toast(msg){
const t = $("toast"); t.textContent = msg; t.classList.add("show");
clearTimeout(toastT); toastT = setTimeout(() => t.classList.remove("show"), 1600);
}
/* ---------------- state sync: SSE push + slow-poll safety net -------- */
async function tick(){
try {
const s = await (await fetch("/live/state.json?ts=" + Date.now(), {cache:"no-store"})).json();
lastOk = Date.now();
$("file").textContent = s.file;
$("notebar").hidden = !s.watching;
$("werr").hidden = !s.watcher_error; // watcher crash banner
if (s.watcher_error) $("werrmsg").textContent = s.watcher_error;
if (s.version !== version){
version = s.version; steps = s.steps;
lintFindings = null; // the file changed: re-lint on demand
if (lintOn) loadFindings().then(renderLint);
renderList(); renderSpark();
if (!steps.length){ // timeline cleared
viewing = -1; $("frame").hidden = true; $("empty").hidden = false;
$("counts").hidden = true; $("ercchip").hidden = true;
$("sheettabs").hidden = true; renderErc(); renderLint();
} else if (following) view(steps.length - 1);
else {
if (viewing >= steps.length) viewing = steps.length - 1;
highlight(); renderErc(); renderLint();
}
}
} catch (e) { /* transient — dot goes stale below */ }
$("dot").classList.toggle("stale", Date.now() - lastOk > 20000);
}
try { // pushed {"version": N} on every change
const es = new EventSource("/live/events");
es.onmessage = () => tick();
} catch (e) { /* fall back to polling alone */ }
setInterval(tick, 8000); tick();
/* ---------------- timeline ---------------- */
function badge(st){
if (!st.parts) return '<span class="badge mute">empty</span>';
if (st.erc_pending) return '<span class="badge mute">ERC…</span>';
if (st.erc_err == null) return "";
if (st.erc_err === 0 && (st.erc_warn|0) === 0) return '<span class="badge ok">ERC ✓</span>';
if (st.erc_err > 0) return `<span class="badge err">${st.erc_err}E ${st.erc_warn||0}W</span>`;
return `<span class="badge warn">${st.erc_warn}W</span>`;
}
function relTime(ts){
if (!ts) return "";
const d = Math.max(0, (Date.now()/1000) - ts);
if (d < 50) return "· just now";
if (d < 3600) return "· " + Math.round(d/60) + "m ago";
return "· " + Math.floor(d/3600) + "h" + Math.round(d%3600/60) + "m ago";
}
function esc(s){ return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">"); }
function renderList(){
const list = $("list");
list.innerHTML = steps.length ? "" : '<div class="step">no steps yet</div>';
// index-based (not st.n): the server trims old steps but keeps n monotonic
for (let i = steps.length - 1; i >= 0; i--){
const st = steps[i], isLast = i === steps.length - 1;
const prev = steps[i - 1];
const dp = prev && st.parts != null && prev.parts != null ? st.parts - prev.parts : 0;
const dn = prev && st.nets != null && prev.nets != null ? st.nets - prev.nets : 0;
let delta = (dp ? ` <span class="d">${dp > 0 ? "+" : ""}${dp}p</span>` : "") +
(dn ? ` <span class="d">${dn > 0 ? "+" : ""}${dn}n</span>` : "");
if (prev && st.erc_err != null && prev.erc_err != null && st.erc_err > prev.erc_err)
delta += ` <span class="d" style="color:var(--err)">+${st.erc_err - prev.erc_err}E new</span>`;
const d = document.createElement("button"); // keyboard/AT reachable
d.type = "button";
d.className = "step"; d.dataset.i = i;
d.innerHTML =
`<div class="row"><span class="n">#${st.n}</span><span class="t">${esc(st.time)} ${relTime(st.ts)}</span>` +
(isLast ? '<span class="live-tag">LIVE</span>' : "") + badge(st) + "</div>" +
(st.note ? `<div class="note">${esc(st.note)}</div>` : "") +
(st.parts != null ? `<div class="meta">${st.parts} parts · ${st.nets} nets${delta}</div>` : "");
d.onclick = () => { stopPlay(); following = isLast; view(i); };
list.appendChild(d);
}
highlight();
}
function highlight(){
document.querySelectorAll(".step").forEach(e => {
const on = +e.dataset.i === viewing;
e.classList.toggle("active", on);
if (e.dataset.i != null) e.setAttribute("aria-current", on ? "step" : "false");
});
$("follow").hidden = following;
$("prev").disabled = viewing <= 0;
$("next").disabled = viewing >= steps.length - 1;
$("diff").disabled = viewing <= 0;
$("stepno").textContent = steps.length && viewing >= 0
? `${viewing + 1} / ${steps.length}` : "–";
}
function renderSpark(){
const svg = $("spark");
const pts = steps.filter(s => s.parts != null);
if (pts.length < 2){ svg.innerHTML = ""; return; }
const w = 96, h = 22, max = Math.max(...pts.map(s => s.parts), 1);
const xy = pts.map((s, i) => [i / (pts.length - 1) * (w - 4) + 2,
h - 3 - (s.parts / max) * (h - 7)]);
let inner = `<polyline points="${xy.map(p => p.map(v => v.toFixed(1)).join(",")).join(" ")}"
fill="none" stroke="var(--data)" stroke-width="1.4"/>`;
pts.forEach((s, i) => {
if (s.erc_err) inner += `<circle cx="${xy[i][0].toFixed(1)}" cy="${xy[i][1].toFixed(1)}" r="2" fill="var(--err)"/>`;
});
svg.innerHTML = inner;
svg.setAttribute("title", "parts per step · red = ERC errors");
}
/* ---------------- SVG loading (inline for getBBox crop) ---------------- */
async function fetchSvg(st, idx = 0){
const files = sheetsOf(st);
const file = files[Math.min(idx, files.length - 1)];
if (!file) throw new Error("no svg");
const url = "/live/" + file + "?v=" + (st.ts || st.n); // unique -> cacheable
if (svgCache.has(url)) return svgCache.get(url);
const text = await (await fetch(url)).text();
const body = text.slice(Math.max(0, text.indexOf("<svg"))); // strip XML prolog
svgCache.set(url, body);
if (svgCache.size > 24) svgCache.delete(svgCache.keys().next().value);
return body;
}
function renderSheetTabs(st){
const files = sheetsOf(st), tabs = $("sheettabs");
tabs.hidden = files.length < 2;
if (tabs.hidden){ tabs.innerHTML = ""; return; }
tabs.innerHTML = "";
files.forEach((f, k) => {
const b = document.createElement("button");
b.className = "ui" + (k === sheetIdx ? " on" : "");
b.textContent = (st.sheet_names || [])[k] || `sheet ${k + 1}`;
b.onclick = () => { sheetIdx = k; view(viewing); };
tabs.appendChild(b);
});
}
async function view(i){
if (i < 0 || i >= steps.length) return;
const st = steps[i]; viewing = i;
sheetIdx = Math.min(sheetIdx, sheetsOf(st).length - 1);
if (st.parts != null){
$("counts").hidden = false;
$("parts").textContent = st.parts; $("nets").textContent = st.nets;
}
if (st.erc_pending){
$("ercchip").hidden = false;
$("erctot").textContent = "…"; $("ercchip").className = "chip";
} else if (st.erc_err != null){
$("ercchip").hidden = false;
$("erctot").textContent = `${st.erc_err}E ${st.erc_warn || 0}W`;
$("ercchip").className = "chip " + (st.erc_err ? "erc-bad" : "erc-ok");
}
highlight(); renderSheetTabs(st);
try {
const body = await fetchSvg(st, sheetIdx);
if (viewing !== i) return; // superseded while fetching
const art = $("art");
art.innerHTML = body;
svgEl = art.querySelector("svg");
if (!svgEl) return;
$("frame").hidden = false; $("empty").hidden = true;
pageVB = svgEl.getAttribute("viewBox");
/* KiCad exports the whole page; crop to the drawn content via getBBox */
try {
const b = svgEl.getBBox();
if (b.width > 0 && b.height > 0){
const m = Math.max(b.width, b.height) * 0.04 + 1;
cropVB = `${b.x - m} ${b.y - m} ${b.width + 2*m} ${b.height + 2*m}`;
} else cropVB = null;
} catch (e){ cropVB = null; }
await refreshGhost();
applyVB();
renderErc();
renderLint();
} catch (e) { /* keep the previous frame */ }
}
function vbParts(){
const vb = ((cropped && cropVB) ? cropVB : pageVB) || "0 0 1 1";
return vb.split(/\s+/).map(Number);
}
function artSize(){
const [,, w, h] = vbParts();
return {w: BASE_W, h: Math.round(BASE_W * (h / w || 1))};
}
function applyVB(){
if (!svgEl) return;
const vb = (cropped && cropVB) ? cropVB : pageVB;
const {w, h} = artSize();
for (const el of [svgEl, $("ghost").querySelector("svg")].filter(Boolean)){
if (vb) el.setAttribute("viewBox", vb);
el.removeAttribute("width"); el.removeAttribute("height");
el.style.width = w + "px"; el.style.height = h + "px";
}
for (const ov of [$("ovl"), $("fovl")]){ // ERC + lint overlays track the frame
if (vb) ov.setAttribute("viewBox", vb);
ov.style.width = w + "px"; ov.style.height = h + "px";
}
$("crop").classList.toggle("on", cropped);
applyT();
}
/* ---------------- diff ghost (previous step, red) ---------------- */
async function refreshGhost(){
const g = $("ghost");
if (!diffOn || viewing <= 0){ g.hidden = true; g.innerHTML = ""; return; }
try {
g.innerHTML = await fetchSvg(steps[viewing - 1], sheetIdx);
g.hidden = false;
} catch (e){ g.hidden = true; }
}
/* ---------------- ERC markers + panel ---------------- */
/* kicad-cli erc --format json reports positions in mm/100 (observed v8/v9);
pick the scale that lands the most markers inside the page. */
function ercScale(items){
if (!pageVB) return null;
const [x, y, w, h] = pageVB.split(/\s+/).map(Number);
for (const s of [100, 1]){
const inside = items.filter(v => v.x != null &&
v.x*s >= x && v.x*s <= x + w && v.y*s >= y && v.y*s <= y + h).length;
if (items.length && inside >= Math.max(1, items.length / 2)) return s;
}
return null;
}
const violKey = v => v.type + "|" + (v.item || "");
function renderErc(){
const st = steps[viewing] || {};
const items = st.erc || [];
$("ercpanel").hidden = !items.length && !st.erc_pending;
const list = $("erclist");
list.innerHTML = st.erc_pending
? '<div class="viol"><span class="tx">ERC running…</span></div>' : "";
/* diff vs the previous step: what is NEW here, what got resolved */
const prevErc = viewing > 0 ? (steps[viewing - 1].erc || null) : null;
const prevSet = new Set((prevErc || []).map(violKey));
const curSet = new Set(items.map(violKey));
const fresh = prevErc ? items.filter(v => !prevSet.has(violKey(v))) : [];
const resolved = prevErc ? [...prevSet].filter(k => !curSet.has(k)).length : 0;
$("erccnt").innerHTML = st.erc_pending ? "…" : !items.length ? "" :
`${st.erc_err || 0}E ${st.erc_warn || 0}W` +
(fresh.length ? ` <span style="color:var(--err)">+${fresh.length} new</span>` : "") +
(resolved ? ` <span class="erc-resolved">−${resolved} resolved</span>` : "");
const freshSet = new Set(fresh.map(violKey));
const ordered = [...items].sort((a, b) =>
(freshSet.has(violKey(b)) ? 1 : 0) - (freshSet.has(violKey(a)) ? 1 : 0));
ordered.forEach(v => {
const k = items.indexOf(v);
const b = document.createElement("button");
b.className = "viol";
b.innerHTML = `<span class="sv ${v.severity === "error" ? "e" : "w"}">` +
(v.severity === "error" ? "✕" : "▲") + "</span>" +
(freshSet.has(violKey(v)) ? '<span class="newtag">NEW</span>' : "") +
`<span class="tx" title="${esc(v.description)}">${esc(v.description)}</span>` +
`<span class="it" title="${esc(v.item || "")}">${esc(v.item || "")}</span>`;
b.onclick = () => jumpToViolation(k);
list.appendChild(b);
});
/* markers — positions are only trustworthy on single-sheet designs */
const ovl = $("ovl");
const scale = sheetsOf(st).length > 1 ? null : ercScale(items);
if (!ercOn || !scale || !items.length){ ovl.hidden = true; ovl.innerHTML = ""; return; }
ovl.hidden = false;
ovl.innerHTML = items.map((v, k) => {
if (v.x == null) return "";
const col = v.severity === "error" ? "var(--err)" : "var(--warn)";
return `<g class="mk" id="mk-${k}">
<circle class="ring" cx="${v.x*scale}" cy="${v.y*scale}" r="3.2" stroke="${col}"/>
<circle cx="${v.x*scale}" cy="${v.y*scale}" r="1.1" fill="${col}"/>
<title>${esc(v.severity)}: ${esc(v.description)}\n${esc(v.item || "")}</title></g>`;
}).join("");
applyVB();
}
function jumpToViolation(k){
const st = steps[viewing] || {};
const v = (st.erc || [])[k];
const scale = sheetsOf(st).length > 1 ? null : ercScale(st.erc || []);
if (!v || v.x == null || !scale){ toast("no locatable position for this finding"); return; }
if (!ercOn){ ercOn = true; $("ercmk").classList.add("on"); renderErc(); }
centerOn(v.x * scale, v.y * scale);
const mk = document.getElementById("mk-" + k);
if (mk){ mk.classList.remove("flash"); void mk.getBBox; mk.classList.add("flash");
setTimeout(() => mk.classList.remove("flash"), 1800); }
}
/* ---------------- akcli lint findings (offline nets/geom/layout) ------- */
/* akcli finding.pos is in MILS; the KiCad SVG viewBox is in MM (verified:
viewBox width == page width in mm). 1 mil = 0.0254 mm, so ONE linear factor
maps every finding into the SVG's own frame — no per-run scale guessing
like ERC (whose kicad-cli JSON coords are in an ambiguous mm/100 unit). */
const MIL_TO_MM = 0.0254;
async function loadFindings(){
try {
const doc = await (await fetch("/api/findings", {cache: "no-store"})).json();
if (doc.error){ lintFindings = []; lintErr = doc.error; }
else { lintFindings = doc.findings || []; lintErr = null; }
} catch (e){ lintFindings = []; lintErr = "server unavailable"; }
}
function positionedFindings(){ return (lintFindings || []).filter(f => Array.isArray(f.pos)); }
function renderLint(){
const fovl = $("fovl");
const st = steps[viewing] || {};
// pos is root-frame only, so markers are trustworthy on single-sheet views
const multi = sheetsOf(st).length > 1;
const found = positionedFindings();
if (!lintOn || multi || !found.length){ fovl.hidden = true; fovl.innerHTML = ""; return; }
fovl.hidden = false;
fovl.innerHTML = found.map((f, k) => {
const x = f.pos[0] * MIL_TO_MM, y = f.pos[1] * MIL_TO_MM, col = "var(--data)";
return `<g class="mk" id="fmk-${k}">
<rect class="ring" x="${x - 3.2}" y="${y - 3.2}" width="6.4" height="6.4" rx="1" stroke="${col}"/>
<circle cx="${x}" cy="${y}" r="1.1" fill="${col}"/>
<title>${esc(f.severity)} [${esc(f.code)}]: ${esc(f.message)}</title></g>`;
}).join("");
found.forEach((f, k) => {
const g = document.getElementById("fmk-" + k);
if (g) g.onclick = () => jumpToFinding(k);
});
applyVB();
}
function jumpToFinding(k){
const st = steps[viewing] || {};
const f = positionedFindings()[k];
if (!f || sheetsOf(st).length > 1){ toast("no locatable position for this finding"); return; }
if (!lintOn){ lintOn = true; $("lintmk").classList.add("on"); renderLint(); }
centerOn(f.pos[0] * MIL_TO_MM, f.pos[1] * MIL_TO_MM);
const mk = document.getElementById("fmk-" + k);
if (mk){ mk.classList.remove("flash"); void mk.getBBox; mk.classList.add("flash");
setTimeout(() => mk.classList.remove("flash"), 1800); }
}
async function toggleLint(){
lintOn = !lintOn; $("lintmk").classList.toggle("on", lintOn);
if (lintOn && lintFindings === null) await loadFindings();
renderLint();
if (lintOn){
if (lintErr) toast("lint: " + lintErr);
else toast(`${(lintFindings || []).length} lint finding(s) · ` +
`${positionedFindings().length} locatable`);
}
}
$("lintmk").onclick = toggleLint;
function centerOn(mx, my){
const [x, y, w, h] = vbParts();
const {w: aw, h: ah} = artSize();
const px = (mx - x) / w * aw + PAD, py = (my - y) / h * ah + PAD;
leaveFit();
scale = Math.max(scale, 1.2);
tx = canvas.clientWidth / 2 - scale * px;
ty = canvas.clientHeight / 2 - scale * py;
applyT();
}
/* ---------------- toolbar ---------------- */
$("crop").onclick = () => { cropped = !cropped; scale = null; applyVB(); };
$("diff").onclick = async () => {
diffOn = !diffOn; $("diff").classList.toggle("on", diffOn);
await refreshGhost(); applyVB();
if (diffOn) toast("previous step ghosted in red");
};
$("ercmk").onclick = () => { ercOn = !ercOn; $("ercmk").classList.toggle("on", ercOn); renderErc(); };
function applyInk(){ $("frame").classList.toggle("ink", inkOn); $("ink").classList.toggle("on", inkOn); }
$("ink").onclick = () => { inkOn = !inkOn; save("akcli-live-ink", inkOn); applyInk(); };
applyInk();
$("follow").onclick = () => { stopPlay(); following = true; view(steps.length - 1); };
$("prev").onclick = () => { stopPlay(); following = false; view(viewing - 1); };
$("next").onclick = () => { stopPlay(); view(viewing + 1); following = (viewing === steps.length - 1); };
$("opensvg").onclick = () => {
const st = steps[viewing];
if (!st) return;
const f = sheetsOf(st)[Math.min(sheetIdx, sheetsOf(st).length - 1)];
// ?v= fingerprint: the server only marks fingerprinted URLs immutable
window.open("/live/" + f + "?v=" + (st.ts || st.n), "_blank");
};
/* overlays: remember the opener, move focus into the dialog, restore on close */
let lastFocus = null;
function openOv(id){
lastFocus = document.activeElement;
const o = $(id); o.hidden = false;
const box = o.querySelector('[role="dialog"]');
if (box) box.focus();
}
function closeOv(id){
const o = $(id);
if (o.hidden) return;
o.hidden = true;
if (lastFocus && document.contains(lastFocus)) lastFocus.focus();
lastFocus = null;
}
$("helpbtn").onclick = () => openOv("helpov");
/* note the next step (server writes note.txt next to the watched file) */
async function sendNote(){
const v = $("noteinp").value.trim();
if (!v) return;
try {
const r = await fetch("/live/note", {method: "POST", body: v});
if (r.ok){ $("noteinp").value = ""; toast("noted — appears on the next step"); }
else toast("note failed: " + (await r.json()).error);
} catch (e){ toast("note failed"); }
}
$("notebtn").onclick = sendNote;
$("noteinp").addEventListener("keydown", e => { if (e.key === "Enter") sendNote(); });
$("clearbtn").onclick = async () => {
if (!steps.length) return;
if (!confirm("Clear the whole timeline? Exported step SVGs are deleted.")) return;
try { await fetch("/live/clear", {method: "POST"}); svgCache.clear(); following = true; }
catch (e){ toast("clear failed"); }
};
$("helpov").addEventListener("mousedown", e => { if (e.target === $("helpov")) closeOv("helpov"); });
/* ---------------- BOM overlay (offline; network only on demand) -------- */
function bomError(msg){
$("bomtable").innerHTML =
`<div style="color:var(--err); padding:6px 2px">${esc(msg)}</div>` +
`<div style="color:var(--faint); font-size:11px; margin-top:6px">` +
`retry: press <span class="kbd">B</span> or click “⇅ check purchasability” again</div>`;
}
async function openBom(check){
if ($("bomov").hidden) openOv("bomov");
$("bomtable").textContent = check ? "querying the catalog…" : "loading…";
$("bomsum").textContent = "";
let doc;
try {
doc = await (await fetch("/live/bom" + (check ? "?check=1" : ""))).json();
} catch (e){ bomError("server unavailable"); return; }
if (doc.error){ bomError(doc.error); return; }
const stCls = s => s === "ok" ? "st-ok" : s === "no-part-id" ? "st-mute"
: s === "low-stock" ? "st-warn" : "st-bad";
const cols = doc.checked
? ["refs", "qty", "value", "part", "status", "stock", "unit", "ext", "note"]
: ["refs", "qty", "value", "footprint", "part id"];
let h = "<table><tr>" + cols.map(c => `<th>${c}</th>`).join("") + "</tr>";
for (const ln of doc.lines){
const part = ln.lcsc || ln.mpn || "—";
h += "<tr>" +
`<td>${esc(ln.refs.join(","))}</td><td>${ln.qty}</td>` +
`<td>${esc(ln.value || "—")}</td>`;
if (doc.checked){
const p = ln.part || {};
// datasheet icon-link when the ?check=1 resolver found one; a page-link
// (JS-rendered product page) is flagged with an external-page glyph
let ds = "";
if (ln.datasheet && ln.datasheet.url){
const page = ln.datasheet.kind === "page";
ds = ` <a class="dslink" href="${esc(ln.datasheet.url)}" target="_blank"` +
` rel="noopener" title="${page ? "product page (open in browser)" : "datasheet PDF"}">` +
`${page ? "⧉" : "▤"}</a>`;
}
h += `<td>${esc(part)}${ds}</td>` +
`<td class="${stCls(ln.status)}">${esc(ln.status)}</td>` +
`<td>${p.stock ?? "—"}</td>` +
`<td>${ln.unit_price != null ? "$" + ln.unit_price.toFixed(4) : "—"}</td>` +
`<td>${ln.ext_price != null ? "$" + ln.ext_price.toFixed(2) : "—"}</td>` +
`<td class="st-mute">${esc(ln.note || "")}</td>`;
} else {
h += `<td>${esc(ln.footprint || "—")}</td>` +
`<td class="${part === "—" ? "st-mute" : ""}">${esc(part)}</td>`;
}
h += "</tr>";
}
$("bomtable").innerHTML = h + "</table>";
if (doc.checked && doc.totals){
const t = doc.totals;
$("bomsum").textContent =
`${t.lines} lines · ${t.ok} ok · ${t.problems} problems · ` +
`${t.no_part_id} without id · est. $${t.est_cost.toFixed(2)}/board`;
} else {
$("bomsum").textContent = doc.lines.length + " lines (offline view)";
}
}
$("bombtn").onclick = () => openBom(false);
$("bomcheck").onclick = () => openBom(true);
$("bomov").addEventListener("mousedown", e => { if (e.target === $("bomov")) closeOv("bomov"); });
/* replay: walk the whole timeline, then resume following */
function stopPlay(){ if (playT){ clearInterval(playT); playT = null; $("play").textContent = "▶ replay"; } }
$("play").onclick = () => {
if (playT){ stopPlay(); return; }
if (steps.length < 2) return;
following = false;
let i = viewing >= steps.length - 1 ? 0 : viewing;
view(i);
$("play").textContent = "⏸";
playT = setInterval(() => {
i++;
if (i >= steps.length){ stopPlay(); following = true; highlight(); return; }
view(i);
}, 900);
};
/* PNG export of the current (possibly cropped) view */
$("dlpng").onclick = () => {
if (!svgEl || !steps[viewing]) return;
const [, , w, h] = vbParts();
const outW = Math.min(2600, Math.max(1200, Math.round(BASE_W * 2)));
const outH = Math.round(outW * h / w);
const clone = svgEl.cloneNode(true);
clone.removeAttribute("style"); // drop the on-screen px sizing
clone.setAttribute("width", outW); clone.setAttribute("height", outH);
const blob = new Blob([new XMLSerializer().serializeToString(clone)],
{type: "image/svg+xml;charset=utf-8"});
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
const cv = document.createElement("canvas");
cv.width = outW; cv.height = outH;
const cx = cv.getContext("2d");
cx.fillStyle = "#fff"; cx.fillRect(0, 0, outW, outH);
cx.drawImage(img, 0, 0, outW, outH);
URL.revokeObjectURL(url);
cv.toBlob(b => {
const a = document.createElement("a");
a.href = URL.createObjectURL(b);
a.download = ($("file").textContent || "sheet").replace(/\.kicad_sch$/, "") +
"-step" + (viewing + 1) + ".png";
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 5000);
}, "image/png");
};
img.onerror = () => { URL.revokeObjectURL(url); toast("PNG export failed"); };
img.src = url;
};
/* ---------------- keyboard ---------------- */
document.addEventListener("keydown", e => {
if (/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName)) return;
if (e.key === "Escape"){ closeOv("helpov"); closeOv("bomov"); return; }
if (e.key === "b" || e.key === "B"){ openBom(false); return; }
if (e.key === "ArrowLeft") $("prev").click();
else if (e.key === "ArrowRight") $("next").click();
else if (e.key === "l" || e.key === "L") $("follow").click();
else if (e.key === "h" || e.key === "H") location.href = "/";
else if (e.key === "f" || e.key === "F" || e.key === "0") $("zfit").click();
else if (e.key === "c" || e.key === "C") $("crop").click();
else if (e.key === "d" || e.key === "D") $("diff").click();
else if (e.key === "e" || e.key === "E") $("ercmk").click();
else if (e.key === "g" || e.key === "G") $("lintmk").click();
else if (e.key === "i" || e.key === "I") $("ink").click();
else if (e.key === " "){ e.preventDefault(); $("play").click(); }
else if (e.key === "+" || e.key === "=") $("zin").click();
else if (e.key === "-") $("zout").click();
else if (e.key === "?") $("helpov").hidden ? openOv("helpov") : closeOv("helpov");
});
/* ---------------- zoom / pan ---------------- */
let scale = null, tx = 0, ty = 0; // scale = null -> fit mode
const canvas = $("canvas"), frame = $("frame");
function contentSize(){
const {w, h} = svgEl ? artSize() : {w: BASE_W, h: BASE_W};
return {w: w + 2*PAD, h: h + 2*PAD};
}
function applyT(){
const {w, h} = contentSize();
if (scale === null){
const s = Math.min((canvas.clientWidth - 32) / w, (canvas.clientHeight - 32) / h, 4) || 1;
frame.style.transform = `translate(${(canvas.clientWidth - w*s)/2}px,` +
`${(canvas.clientHeight - h*s)/2}px) scale(${s})`;
$("zlvl").textContent = "fit";
} else {
frame.style.transform = `translate(${tx}px,${ty}px) scale(${scale})`;
$("zlvl").textContent = Math.round(scale * 100) + "%";
}
}
new ResizeObserver(applyT).observe(canvas);
function leaveFit(){
if (scale !== null) return;
const m = new DOMMatrix(getComputedStyle(frame).transform);
scale = m.a; tx = m.e; ty = m.f;
}
function setScale(s, cx, cy){
const r = canvas.getBoundingClientRect();
cx = cx ?? r.width / 2; cy = cy ?? r.height / 2;
leaveFit();
const k = s / scale;
tx = cx - k * (cx - tx); ty = cy - k * (cy - ty); scale = s;
applyT();
}
$("zin").onclick = () => { leaveFit(); setScale(Math.min(10, scale * 1.25)); };
$("zout").onclick = () => { leaveFit(); setScale(Math.max(.05, scale / 1.25)); };
$("zfit").onclick = () => { scale = null; applyT(); };
canvas.addEventListener("wheel", e => {
e.preventDefault(); leaveFit();
const r = canvas.getBoundingClientRect();
setScale(Math.min(10, Math.max(.05, scale * (e.deltaY < 0 ? 1.12 : 1/1.12))),
e.clientX - r.left, e.clientY - r.top);
}, {passive: false});
let drag = null;
canvas.addEventListener("pointerdown", e => {
leaveFit(); drag = {x: e.clientX - tx, y: e.clientY - ty};
canvas.classList.add("dragging"); canvas.setPointerCapture(e.pointerId);
});
canvas.addEventListener("pointermove", e => {
if (!drag) return; tx = e.clientX - drag.x; ty = e.clientY - drag.y; applyT();
});
canvas.addEventListener("pointerup", () => { drag = null; canvas.classList.remove("dragging"); });
canvas.addEventListener("dblclick", () => { scale === null ? setScale(1.5) : (scale = null, applyT()); });
</script>
</body>
</html>