PCB ReTrace initial

This commit is contained in:
Taras Greben 2025-12-21 00:31:26 +02:00
commit f06b8a68b8
20 changed files with 7089 additions and 0 deletions

158
docs/canvas-ui.js Normal file
View file

@ -0,0 +1,158 @@
/* canvas-ui.js - Shared Canvas Logic */
class PanZoomCanvas {
constructor(id, onDraw, onClick, onDragPt) {
this.canvas = document.getElementById(id);
this.container = this.canvas ? this.canvas.parentElement : null;
this.ctx = this.canvas ? this.canvas.getContext('2d') : null;
this.bmp = null;
this.t = {x:0, y:0, k:1}; // Changed default k to 1, but fit() will override
this.drag = false;
this.lm = {x:0, y:0};
this.activePtIdx = -1;
this.isMirrored = false;
this.onDraw = onDraw;
this.onClick = onClick;
this.onDragPt = onDragPt;
this.onMouseMove = null;
if(this.container) {
new ResizeObserver(() => {
if(this.container && this.container.clientWidth > 0) {
this.canvas.width = this.container.clientWidth;
this.canvas.height = this.container.clientHeight;
this.draw();
}
}).observe(this.container);
this.initEvents();
}
}
initEvents() {
this.canvas.onwheel = e => {
e.preventDefault();
const f = Math.exp(-e.deltaY * 0.001);
const r = this.canvas.getBoundingClientRect();
const mx = e.clientX - r.left, my = e.clientY - r.top;
const wx = (mx - this.t.x) / this.t.k, wy = (my - this.t.y) / this.t.k;
this.t.k = Math.max(0.01, Math.min(20, this.t.k * f));
this.t.x = mx - wx * this.t.k;
this.t.y = my - wy * this.t.k;
this.draw();
};
this.canvas.onmousedown = e => {
const coords = this.getImgCoords(e.clientX, e.clientY);
if (this.onDragPt) {
const idx = this.onDragPt(coords.x, coords.y, 'check');
if (idx !== -1) {
this.activePtIdx = idx;
this.drag = true;
this.lm = { x: e.clientX, y: e.clientY };
return;
}
}
this.drag = true;
this.lm = { x: e.clientX, y: e.clientY };
this.activePtIdx = -1;
};
this.canvas.oncontextmenu = e => {
e.preventDefault();
const coords = this.getImgCoords(e.clientX, e.clientY);
if (this.onDragPt) this.onDragPt(coords.x, coords.y, 'delete');
};
window.addEventListener('mousemove', e => {
if (this.drag) {
if (this.activePtIdx !== -1) {
const r = this.canvas.getBoundingClientRect();
const curr = this.getImgCoords(e.clientX, e.clientY);
const prev = this.getImgCoords(this.lm.x, this.lm.y);
const dx = curr.x - prev.x;
const dy = curr.y - prev.y;
this.onDragPt(dx, dy, 'move', this.activePtIdx);
this.lm = { x: e.clientX, y: e.clientY };
} else {
this.t.x += e.clientX - this.lm.x;
this.t.y += e.clientY - this.lm.y;
this.lm = { x: e.clientX, y: e.clientY };
this.draw();
}
}
if(this.onMouseMove && e.target === this.canvas) {
const coords = this.getImgCoords(e.clientX, e.clientY);
this.onMouseMove(coords.x, coords.y);
}
});
window.addEventListener('mouseup', e => {
if (this.drag && this.activePtIdx === -1 && e.shiftKey && this.onClick) {
const coords = this.getImgCoords(e.clientX, e.clientY);
this.onClick(coords.x, coords.y);
}
this.drag = false;
this.activePtIdx = -1;
});
}
getImgCoords(screenX, screenY) {
const r = this.canvas.getBoundingClientRect();
const mx = (screenX - r.left - this.t.x) / this.t.k;
const my = (screenY - r.top - this.t.y) / this.t.k;
if (this.isMirrored && this.bmp) { return { x: this.bmp.width - mx, y: my }; }
return { x: mx, y: my };
}
setMirror(val) { this.isMirrored = val; this.draw(); }
setImage(b) { this.bmp = b; this.draw(); }
setDimmed(isDimmed) {
if(isDimmed) this.canvas.style.filter = "brightness(0.4) grayscale(100%)";
else this.canvas.style.filter = "none";
}
// [NEW] Fit Image to Container
fit() {
if(!this.bmp || !this.canvas) return;
const vw = this.canvas.width;
const vh = this.canvas.height;
if (vw === 0 || vh === 0) return;
const iw = this.bmp.width;
const ih = this.bmp.height;
// Scale to fit
const scale = Math.min(vw / iw, vh / ih);
// Center
const cx = (vw - iw * scale) / 2;
const cy = (vh - ih * scale) / 2;
this.t = { x: cx, y: cy, k: scale };
this.draw();
}
draw() {
if(!this.ctx) return;
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.ctx.save();
this.ctx.translate(this.t.x, this.t.y);
this.ctx.scale(this.t.k, this.t.k);
if (this.bmp) {
this.ctx.save();
if (this.isMirrored) {
this.ctx.translate(this.bmp.width, 0);
this.ctx.scale(-1, 1);
}
this.ctx.drawImage(this.bmp, 0, 0);
this.ctx.restore();
}
if (this.onDraw) this.onDraw(this.ctx, this.t.k);
this.ctx.restore();
}
}

238
docs/coil.html Normal file
View file

@ -0,0 +1,238 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag("consent","default",{ad_storage:"granted",ad_user_data:"granted",ad_personalization:"granted",analytics_storage:"granted"});gtag("consent","default",{ad_storage:"denied",ad_user_data:"denied",ad_personalization:"denied",analytics_storage:"denied",region:"AT,BE,BG,HR,CY,CZ,DK,EE,FI,FR,DE,GR,HU,IE,IT,LV,LT,LU,MT,NL,PL,PT,RO,SK,SI,ES,SE,IS,LI,NO,GB,CH,UA".split(",")});</script>
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-X1MKL6C4B0"></script>
<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag("js",new Date());gtag("config","G-X1MKL6C4B0");</script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Air Core Coil</title>
<script src="tool-bridge.js"></script>
<script src="spyglass.js"></script>
<link rel="stylesheet" href="tool-style.css">
<style>
:root {
/* Copper Theme */
--accent: #ea580c;
--copper-dark: #9a3412;
--bg: #fff7ed;
--border: #fed7aa;
--focus-ring: #ffedd5;
}
/* Layout overrides */
.coil-viz {
width: 100%; height: 120px; display: flex; align-items: center; justify-content: center;
background: linear-gradient(to bottom, #fff7ed, #fff);
border: 1px solid #fed7aa; border-radius: 0.5rem;
overflow: hidden; position: relative; flex-shrink: 0; margin-bottom: 1rem;
}
.coil-viz svg {
width: 100%;
height: 100%;
display: block;
}
svg path {
fill: none; stroke: url(#copperGradient); stroke-width: 4; stroke-linecap: round;
filter: drop-shadow(0px 2px 2px rgba(0,0,0,0.2));
}
/* Specific Label Color from original file */
label { color: #78350f !important; opacity: 1 !important; }
/* MOBILE OVERRIDES */
@media (max-width: 600px) {
.result-val { font-size: 1.5rem; }
/* NEW: Smaller Coil Graphic on Mobile */
.coil-viz {
height: 80px; /* Reduced from 120px */
margin-bottom: 0.5rem;
}
}
</style>
</head>
<body>
<div class="container">
<h1 id="app-title">Air Core Calc</h1>
<div class="top-section">
<div id="spyglass-col" class="col-spyglass">
<div class="spyglass-wrapper">
<canvas id="tool-canvas" class="spyglass-canvas" width="600" height="600"></canvas>
<div id="tool-zoom" class="zoom-badge">2x</div>
</div>
<div class="spyglass-hint">Scroll to zoom</div>
<div id="view-selector"></div>
</div>
<div class="col-controls">
<div class="input-grid">
<div class="form-group">
<label>Turns (N)</label>
<div class="input-wrapper">
<input type="number" id="inp-turns" value="10" min="1" step="1" oninput="updateLength()">
<span class="unit">T</span>
</div>
</div>
<div class="form-group">
<label>Diameter (D)</label>
<div class="input-wrapper">
<input type="number" id="inp-dia" value="6" min="0.1" step="0.1" oninput="calculate()">
<span class="unit">mm</span>
</div>
</div>
<div class="form-group">
<label>Wire Ø (d)</label>
<div class="input-wrapper">
<input type="number" id="inp-wire" value="0.5" min="0.05" step="0.05" oninput="updateLength()">
<span class="unit">mm</span>
</div>
</div>
<div class="form-group">
<label>Length (L)</label>
<div class="input-wrapper">
<input type="number" id="inp-len" value="5" min="0.1" step="0.1" oninput="calculate()" style="background:#f9fafb; color:#666">
<span class="unit">mm</span>
</div>
</div>
</div>
</div>
</div>
<div class="coil-viz">
<svg width="300" height="100" viewBox="0 0 300 100" id="coil-svg">
<defs>
<linearGradient id="copperGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#fdba74;stop-opacity:1" />
<stop offset="50%" style="stop-color:#ea580c;stop-opacity:1" />
<stop offset="100%" style="stop-color:#7c2d12;stop-opacity:1" />
</linearGradient>
</defs>
<path id="coil-path" d="" />
</svg>
</div>
<div class="result-box">
<div class="result-row" style="background:#fff7ed; border:1px solid #fed7aa;">
<div class="val-group">
<span class="val-text" id="res-val" style="color:#9a3412">---</span>
<span class="meta-text" id="res-desc" style="color:#c2410c">Adjust parameters above</span>
</div>
<button id="btn-select" class="btn-select" style="display:none;" onclick="sendResult()">Select</button>
</div>
</div>
<div class="app-footer">Copyright © 2025 Taras Greben — <a href="https://pcb.etaras.com">pcb.eTaras.com</a></div>
</div>
<script>
let isEmbed = false;
let calculatedValues = { val: '', desc: '' };
let bridge = null;
function init() {
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('embed') || window.self !== window.top) {
isEmbed = true;
document.getElementById('btn-select').style.display = 'block';
document.getElementById('app-title').style.display = 'none';
}
calculate();
}
function parseDescription(desc) {
try {
const regex = /([\d\.]+)T,\s*([\d\.]+)mm ID,\s*([\d\.]+)mm Cu/;
const match = desc.match(regex);
if (match) {
document.getElementById('inp-turns').value = match[1];
document.getElementById('inp-dia').value = match[2];
document.getElementById('inp-wire').value = match[3];
updateLength();
}
} catch(e) {}
}
function updateLength() {
const N = parseFloat(document.getElementById('inp-turns').value) || 0;
const d_wire = parseFloat(document.getElementById('inp-wire').value) || 0;
const len = (N * d_wire * 1.05).toFixed(2);
document.getElementById('inp-len').value = len;
calculate();
}
function calculate() {
const N = parseFloat(document.getElementById('inp-turns').value);
const D_mm = parseFloat(document.getElementById('inp-dia').value);
const l_mm = parseFloat(document.getElementById('inp-len').value);
const w_mm = parseFloat(document.getElementById('inp-wire').value);
if (!N || !D_mm || !l_mm) return;
const d_in = D_mm / 25.4;
const l_in = l_mm / 25.4;
const num = Math.pow(d_in, 2) * Math.pow(N, 2);
const den = (18 * d_in) + (40 * l_in);
let uH = num / den;
let displayVal = "", cleanVal = "";
if (uH < 1) {
let nH = uH * 1000;
displayVal = nH.toFixed(0) + " nH";
cleanVal = nH.toFixed(0) + "nH";
} else {
displayVal = uH.toFixed(2) + " µH";
cleanVal = uH.toFixed(2) + "uH";
}
const desc = `Air Core: ${N}T, ${D_mm}mm ID, ${w_mm}mm Cu`;
document.getElementById('res-val').innerText = displayVal;
document.getElementById('res-desc').innerText = desc;
calculatedValues = { val: cleanVal, desc: desc };
drawCoil(N);
}
function drawCoil(turns) {
const path = document.getElementById('coil-path');
const width = 300;
const height = 100;
const padding = 20;
const visualTurns = Math.min(Math.max(turns, 1), 20);
const pitch = (width - padding*2) / visualTurns;
const radius = 24;
const midY = 60;
let d = `M ${padding} ${midY + radius}`;
for (let i = 0; i < visualTurns; i++) {
const x = padding + (i * pitch);
// The magic number 2.5 defines the 'height' of the back loop perspective
d += ` C ${x + pitch*0.2} ${midY - radius*2.5}, ${x + pitch*0.8} ${midY - radius*2.5}, ${x + pitch} ${midY + radius}`;
}
path.setAttribute('d', d);
}
function sendResult() {
if(bridge) bridge.sendResult();
}
init();
bridge = new ToolBridge({
onData: (d) => {
if(d.description) parseDescription(d.description);
calculate();
},
onNominal: () => calculatedValues.val,
onDescription: () => calculatedValues.desc
});
</script>
</body>
</html>

43
docs/common.css Normal file
View file

@ -0,0 +1,43 @@
:root {
--border: #cbd5e1;
--text-muted: #94a3b8;
--bg-default: #f1f5f9;
--text-default: #1e293b;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
margin: 0;
padding: 0;
/* MOBILE VIEWPORT FIX */
height: 100vh; /* Fallback */
height: 100dvh; /* Dynamic height handles mobile toolbars */
display: flex;
flex-direction: column;
overflow: hidden; /* Prevent body scroll, let containers scroll */
}
/* COMPACT FOOTER */
.app-footer {
padding: 0.2rem 0;
text-align: center;
font-size: 0.65rem;
color: var(--text-muted);
border-top: 1px solid var(--border);
background: rgba(255, 255, 255, 0.9); /* Slight transparency */
flex-shrink: 0;
z-index: 20;
width: 100%;
}
.app-footer a {
color: inherit;
text-decoration: none;
border-bottom: 1px dotted var(--border);
}
@keyframes spin {
100% { -webkit-transform: rotate(360deg); transform:rotate(360deg); }
}

172
docs/cv-core.js Normal file
View file

@ -0,0 +1,172 @@
/* cv-core.js - Computer Vision Logic for PCB ReTrace Suite */
class CVManager {
constructor() {
this.ready = false;
this.initAttempts = 0;
this.detector = null;
this.matcher = null;
}
async init() {
if (this.ready) return;
if (typeof cv !== 'undefined' && cv.Mat) {
try {
// Tuned Settings: ORB 5000, Hamming
this.detector = new cv.ORB(5000);
this.matcher = new cv.BFMatcher(cv.NORM_HAMMING, false);
this.ready = true;
console.log("CV Core Initialized");
} catch (e) {
console.error("CV Init Error:", e);
}
} else {
this.initAttempts++;
if (this.initAttempts < 50) setTimeout(() => this.init(), 500);
else console.error("CV Failed to load OpenCV.js");
}
}
// Helper: Project (x,y) using Homography Matrix
projectPoint(x, y, h) {
if (!h || h.length < 9) return null;
const Z = h[6] * x + h[7] * y + h[8];
if (Math.abs(Z) < 0.0001) return null;
return {
x: (h[0] * x + h[1] * y + h[2]) / Z,
y: (h[3] * x + h[4] * y + h[5]) / Z
};
}
// Helper: Resize for processing (Speed optimization)
async createSmallMat(blob) {
const maxWidth = 2000; // Tuned for optimal accuracy/speed
const bmp = await createImageBitmap(blob);
const canvas = document.createElement('canvas');
const scale = Math.min(1, maxWidth / bmp.width);
canvas.width = bmp.width * scale;
canvas.height = bmp.height * scale;
const ctx = canvas.getContext('2d');
ctx.drawImage(bmp, 0, 0, canvas.width, canvas.height);
const mat = cv.imread(canvas);
const gray = new cv.Mat();
cv.cvtColor(mat, gray, cv.COLOR_RGBA2GRAY);
mat.delete();
// Cleanup canvas to free memory
canvas.width = 0; canvas.height = 0;
return { mat: gray, scale: scale, width: bmp.width, height: bmp.height };
}
// Solve Manual Stitch (Least Squares)
solveManual(pairs) {
if (pairs.length < 4) return null;
const srcMat = new cv.Mat(pairs.length, 1, cv.CV_32FC2);
const dstMat = new cv.Mat(pairs.length, 1, cv.CV_32FC2);
for (let i = 0; i < pairs.length; i++) {
srcMat.data32F[i * 2] = pairs[i].s.x;
srcMat.data32F[i * 2 + 1] = pairs[i].s.y;
dstMat.data32F[i * 2] = pairs[i].d.x;
dstMat.data32F[i * 2 + 1] = pairs[i].d.y;
}
// Method 0 = Least Squares (Best for manual precise points)
const H = cv.findHomography(srcMat, dstMat, 0);
if (H.empty()) {
srcMat.delete(); dstMat.delete(); H.delete();
return null;
}
const hd = [];
const Hi = H.inv(cv.DECOMP_LU);
const ihd = [];
for (let i = 0; i < 9; i++) hd.push(H.data64F[i]);
for (let i = 0; i < 9; i++) ihd.push(Hi.data64F[i]);
srcMat.delete(); dstMat.delete(); H.delete(); Hi.delete();
return { hData: hd, invHData: ihd };
}
// Extract Features
async feats(blob) {
if (!this.ready) return null;
const { mat, scale, width, height } = await this.createSmallMat(blob);
const kp = new cv.KeyPointVector();
const des = new cv.Mat();
this.detector.detectAndCompute(mat, new cv.Mat(), kp, des);
mat.delete();
return { kp, des, scale, w: width, h: height };
}
// Chain two homographies: H_total = H2 * H1
multiplyH(H2, H1) {
// H1 and H2 are flat arrays [0..8] representing 3x3 matrices
// Standard Matrix Multiplication
const res = new Array(9);
for (let r = 0; r < 3; r++) {
for (let c = 0; c < 3; c++) {
let sum = 0;
for (let k = 0; k < 3; k++) {
sum += H2[r*3 + k] * H1[k*3 + c];
}
res[r*3 + c] = sum;
}
}
return res;
}
// Find Homography between two feature sets
findH(s, d) {
if (s.des.rows === 0 || d.des.rows === 0) return null;
const mv = new cv.DMatchVectorVector();
this.matcher.knnMatch(s.des, d.des, mv, 2);
const good = [];
for (let i = 0; i < mv.size(); i++) {
const m = mv.get(i).get(0), n = mv.get(i).get(1);
if (m.distance < 0.70 * n.distance) good.push(m); // Ratio Test 0.70
}
mv.delete();
if (good.length > 8) {
const sp = new cv.Mat(good.length, 1, cv.CV_32FC2);
const dp = new cv.Mat(good.length, 1, cv.CV_32FC2);
for (let i = 0; i < good.length; i++) {
const k1 = s.kp.get(good[i].queryIdx);
const k2 = d.kp.get(good[i].trainIdx);
sp.data32F[i * 2] = k1.pt.x / s.scale;
sp.data32F[i * 2 + 1] = k1.pt.y / s.scale;
dp.data32F[i * 2] = k2.pt.x / d.scale;
dp.data32F[i * 2 + 1] = k2.pt.y / d.scale;
}
const mask = new cv.Mat();
// RANSAC 8.0 (Tuned for 2000px resolution)
const H = cv.findHomography(sp, dp, cv.RANSAC, 8.0, mask);
if (H.empty()) {
sp.delete(); dp.delete(); mask.delete(); H.delete();
return null;
}
const hd = [];
const Hi = H.inv(cv.DECOMP_LU);
const ihd = [];
for (let i = 0; i < 9; i++) hd.push(H.data64F[i]);
for (let i = 0; i < 9; i++) ihd.push(Hi.data64F[i]);
sp.delete(); dp.delete(); mask.delete(); H.delete(); Hi.delete();
return { hData: hd, invHData: ihd, matches: good.length };
}
return null;
}
}

143
docs/guide.html Normal file
View file

@ -0,0 +1,143 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>User Guide - PCB ReTrace</title>
<link rel="stylesheet" href="common.css">
<style>
/* Override common.css app locks for document flow */
html, body {
height: auto !important;
overflow-y: auto !important;
display: block !important;
margin: 0; padding: 0;
}
body { background: #f8fafc; color: #334155; line-height: 1.6; }
.container {
max-width: 800px;
margin: 0 auto;
padding: 2rem 1rem;
background: white;
min-height: 100vh;
box-shadow: 0 0 20px rgba(0,0,0,0.05);
box-sizing: border-box;
}
h1 { color: #0f172a; margin-bottom: 0.5rem; border-bottom: 1px solid #e2e8f0; padding-bottom: 1rem; }
h2 { color: #1e293b; margin-top: 2.5rem; margin-bottom: 1rem; font-size: 1.5rem; }
h3 { color: #2563eb; margin-top: 1.5rem; font-size: 1.1rem; }
.badge { display: inline-block; padding: 0.2rem 0.6rem; border-radius: 4px; font-size: 0.75rem; font-weight: bold; background: #e2e8f0; color: #475569; }
.badge.pro { background: #dcfce7; color: #166534; }
ul, ol { padding-left: 1.5rem; margin-bottom: 1rem; }
li { margin-bottom: 0.5rem; }
.tip-box { background: #eff6ff; border-left: 4px solid #2563eb; padding: 1rem; margin: 1.5rem 0; border-radius: 0 4px 4px 0; font-size: 0.95rem; }
.warn-box { background: #fff1f2; border-left: 4px solid #f43f5e; padding: 1rem; margin: 1.5rem 0; border-radius: 0 4px 4px 0; font-size: 0.95rem; }
code { background: #f1f5f9; padding: 0.2rem 0.4rem; border-radius: 3px; font-family: monospace; font-size: 0.9em; color: #0f172a; border: 1px solid #e2e8f0; }
.nav-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem; padding-bottom: 0.5rem; border-bottom: 1px solid #e2e8f0; }
.back-link { text-decoration: none; color: #64748b; font-weight: 600; display: flex; align-items: center; gap: 0.5rem; }
.back-link:hover { color: #2563eb; }
footer { margin-top: 4rem; padding-top: 2rem; border-top: 1px solid #e2e8f0; color: #94a3b8; font-size: 0.8rem; text-align: center; }
</style>
</head>
<body>
<div class="container">
<nav class="nav-header">
<a href="index.html" class="back-link">← Back to Home</a>
<span style="color:#94a3b8; font-size:0.9rem;">Documentation v5.3</span>
</nav>
<h1>PCB ReTrace Guide</h1>
<p class="lead">A local-first browser tool for reverse engineering, documenting, and inspecting printed circuit boards.</p>
<div class="tip-box">
<strong>Privacy Note:</strong> This application runs 100% in your browser. All images and data are stored in your browser's local database (IndexedDB). No data is uploaded to any server.
</div>
<h2>1. Concept & Hierarchy</h2>
<p>ReTrace organizes data to match physical hardware:</p>
<ol>
<li><strong>Device:</strong> The physical unit (e.g., "Multimeter Model X").</li>
<li><strong>Board:</strong> A specific PCB inside the device (e.g., "Mainboard", "Power Supply").</li>
<li><strong>Images:</strong> Photos of the board (Top, Bottom, Angled shots).</li>
</ol>
<h2>2. The Workflow</h2>
<h3>Step 1: Setup</h3>
<p>Create a <strong>New Device</strong> and a <strong>New Board</strong> using the top menu. Then, upload your images. High-resolution, flat photos work best.</p>
<h3>Step 2: Mapping Components (BOM)</h3>
<p>Navigate to the <strong>Images</strong> tab. Click on a component in the photo to mark it.</p>
<ul>
<li>The app switches to the <strong>BOM</strong> list.</li>
<li>Enter the Reference (e.g., <code>R1</code>).</li>
<li>If identifying the value requires calculation, click the tool icon to open the built-in decoders (Resistor Bands, Inductors).</li>
</ul>
<h3>Step 3: Tracing Nets</h3>
<p>Use the <strong>Inspect</strong> and <strong>Nets</strong> tabs to document connectivity.</p>
<ul>
<li><strong>New Net:</strong> In the Inspect tab, click anywhere on the image to create a node. You will be prompted to name the Pin/Pad. If no net is currently active, entering a name will <strong>automatically start a new net</strong>.</li>
<li><strong>Add Nodes:</strong> Click on other pins that belong to the same electrical connection.</li>
<li><strong>Export:</strong> In the Nets tab, you can export a <code>.net</code> file compatible with <strong>KiCad</strong> to help verify your reverse engineering in a professional EDA tool.</li>
</ul>
<h2>3. Computer Vision & Stitching</h2>
<p>ReTrace uses Computer Vision (CV) for <strong>Spatial Alignment</strong>. It allows you to synchronize the view across different images.</p>
<div class="warn-box">
<strong>Important:</strong> The CV engine does <em>not</em> automatically recognize components (it won't read "10k" off a resistor). Its purpose is to mathematically align coordinates between images so you can see through the board.
</div>
<h3>Auto-Stitch (Same Side)</h3>
<p>When you upload a new image, ReTrace <strong>automatically attempts to stitch</strong> it against existing images on the board in the background. If features match (e.g., a close-up vs. a wide shot), connections are created automatically.</p>
<p>You can also run this manually:</p>
<ol>
<li>Open <strong>Image Settings</strong> on your Main image.</li>
<li>Select <strong>Stitch / Connections</strong> -> <strong>Run Auto-CV</strong>.</li>
</ol>
<h3>Manual Stitch (Top vs. Bottom)</h3>
<p>To create an "X-Ray" view where clicking a pin on the Top automatically shows the corresponding pin on the Bottom:</p>
<ol>
<li>Select your <strong>Top Image</strong>.</li>
<li>Go to <strong>Image Settings</strong> -> <strong>Connections</strong>.</li>
<li>Find your <strong>Bottom Image</strong> and click <strong>"Edit Stitch"</strong>.</li>
<li><strong>Flip Target:</strong> Click the <button style="padding:0 4px; font-size:0.8em;">↔ Flip Target</button> button. This mirrors the bottom image horizontally.</li>
<li><strong>Match Points:</strong> Click 4 common features (Vias, Holes, Corners) visible on both images.</li>
<li>Click <strong>Save Match</strong>.</li>
</ol>
<h2>4. Tools</h2>
<p>ReTrace includes standalone calculators accessible from the BOM editor:</p>
<ul>
<li><strong>Resistor:</strong> 3-6 Band decoder with reverse lookup.</li>
<li><strong>Inductor:</strong> Standard and Military codes.</li>
<li><strong>Coil:</strong> Calculator for hand-wound air-core inductors.</li>
</ul>
<h2>5. Data Management</h2>
<ul>
<li><strong>Device Export (ZIP):</strong> Full backup including all boards and images.</li>
<li><strong>Board Export (ZIP):</strong> Portable backup of a single board.</li>
<li><strong>CSV Export:</strong> Standard spreadsheet export for BOM ordering.</li>
<li><strong>KiCad Export (.net):</strong> Exports identified components and traced nets for use in PCB design software.</li>
</ul>
<footer>
Copyright &copy; 2025 Taras Greben.
</footer>
</div>
</body>
</html>

153
docs/index.html Normal file
View file

@ -0,0 +1,153 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag("consent","default",{ad_storage:"granted",ad_user_data:"granted",ad_personalization:"granted",analytics_storage:"granted"});gtag("consent","default",{ad_storage:"denied",ad_user_data:"denied",ad_personalization:"denied",analytics_storage:"denied",region:"AT,BE,BG,HR,CY,CZ,DK,EE,FI,FR,DE,GR,HU,IE,IT,LV,LT,LU,MT,NL,PL,PT,RO,SK,SI,ES,SE,IS,LI,NO,GB,CH,UA".split(",")});</script>
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-X1MKL6C4B0"></script>
<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag("js",new Date());gtag("config","G-X1MKL6C4B0");</script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PCB ReTrace</title>
<link rel="stylesheet" href="common.css">
<style>
/* OVERRIDE COMMON.CSS APP LOCKS */
/* This ensures the landing page scrolls normally and footer is visible */
html, body {
height: auto !important;
overflow-y: auto !important; /* Force scrollbar */
display: block !important; /* Disable app flex layout */
margin: 0;
padding: 0;
background: white;
color: var(--text-default);
}
:root { --hero-bg: #f8fafc; --text-main: #0f172a; --text-sub: #475569; --primary: #2563eb; }
/* HERO SECTION */
.hero {
padding: 4rem 1rem 3rem;
text-align: center;
background: radial-gradient(circle at 50% 50%, #f1f5f9 0%, #fff 70%);
border-bottom: 1px solid #e2e8f0;
}
.hero h1 { font-size: 2.5rem; margin-bottom: 1rem; letter-spacing: -0.05em; color: var(--text-main); }
.hero p { font-size: 1.2rem; color: var(--text-sub); max-width: 600px; margin: 0 auto 2rem; }
.cta-button {
display: inline-block;
background: var(--primary); color: white;
padding: 0.8rem 2rem; border-radius: 2rem;
font-weight: 700; text-decoration: none; font-size: 1.1rem;
box-shadow: 0 4px 6px rgba(37, 99, 235, 0.2);
transition: transform 0.1s, box-shadow 0.1s;
}
.cta-button:hover { transform: translateY(-2px); box-shadow: 0 6px 12px rgba(37, 99, 235, 0.3); }
.cta-button:active { transform: translateY(0); }
.secondary-links { margin-top: 1.5rem; display: flex; gap: 1rem; justify-content: center; font-size: 0.9rem; }
.secondary-links a { color: var(--text-sub); text-decoration: none; border-bottom: 1px dotted #ccc; }
/* FEATURES GRID */
.features {
max-width: 1000px; margin: 3rem auto; padding: 0 1rem;
display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 2rem;
}
.feature-card { padding: 1.5rem; border: 1px solid #e2e8f0; border-radius: 1rem; transition: border-color 0.2s; }
.feature-card:hover { border-color: var(--primary); }
.feature-icon { font-size: 2rem; margin-bottom: 1rem; display: block; }
.feature-card h3 { margin: 0 0 0.5rem; color: var(--text-main); }
.feature-card p { color: var(--text-sub); font-size: 0.95rem; margin: 0; }
/* TOOLS LIST */
.tools-section { background: #f8fafc; padding: 3rem 1rem; border-top: 1px solid #e2e8f0; }
.tools-container { max-width: 800px; margin: 0 auto; }
.tools-title { text-align: center; margin-bottom: 2rem; text-transform: uppercase; letter-spacing: 0.05em; color: #64748b; font-size: 0.9rem; font-weight: 700; }
.tool-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; }
.tool-link {
background: white; border: 1px solid #e2e8f0; border-radius: 0.5rem;
padding: 1rem; text-align: center; text-decoration: none; color: inherit;
transition: transform 0.2s;
}
.tool-link:hover { transform: translateY(-3px); border-color: var(--primary); }
.tool-link strong { display: block; color: var(--primary); margin-bottom: 0.2rem; }
.tool-link span { font-size: 0.8rem; color: #64748b; }
footer.landing-footer {
margin-top: 0;
padding: 2rem 1rem;
text-align: center;
background: white;
border-top: 1px solid #e2e8f0;
color: #94a3b8;
font-size: 0.8rem;
}
footer.landing-footer a { color: inherit; text-decoration: underline; }
@media (max-width: 600px) {
.hero { padding: 3rem 1rem; }
.hero h1 { font-size: 2rem; }
.tool-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="hero">
<h1>PCB ReTrace</h1>
<p>Digitize, document, and reverse engineer printed circuit boards. Map components, trace nets, and inspect layers. Runs entirely in your browser.</p>
<a href="studio.html" class="cta-button">Launch Studio</a>
<div class="secondary-links">
<a href="guide.html">📖 User Guide & Documentation</a>
<a href="https://github.com/dev-lab/pcb-retrace">GitHub Repo</a>
</div>
</div>
<div class="features">
<div class="feature-card">
<span class="feature-icon">🔒</span>
<h3>Privacy First</h3>
<p>Zero cloud upload. All images and data are stored in your browser's local database. Works 100% offline.</p>
</div>
<div class="feature-card">
<span class="feature-icon">🕸️</span>
<h3>Netlist Tracing</h3>
<p>Click-to-trace connectivity across your board. Define nets, label nodes, and export directly to <strong>KiCad</strong>.</p>
</div>
<div class="feature-card">
<span class="feature-icon">👁️</span>
<h3>Auto-Stitch & Inspect</h3>
<p>Align close-up shots with wide-angle views using Computer Vision. Stitch Top vs. Bottom layers for X-Ray inspection.</p>
</div>
</div>
<div class="tools-section">
<div class="tools-container">
<div class="tools-title">Included Utilities</div>
<div class="tool-grid">
<a href="resistor.html" class="tool-link">
<strong>Resistor Decoder</strong>
<span>3-6 Bands & Reverse Lookup</span>
</a>
<a href="inductor.html" class="tool-link">
<strong>Inductor Decoder</strong>
<span>Standard & Military Codes</span>
</a>
<a href="coil.html" class="tool-link">
<strong>Coil Calculator</strong>
<span>Air-core Inductance</span>
</a>
</div>
</div>
</div>
<footer class="landing-footer">
<p>Dual Licensed: <strong>AGPL v3</strong> (Open Source) + <strong>Commercial</strong>.</p>
<p>Copyright © 2025 Taras Greben — <a href="https://pcb.etaras.com">pcb.eTaras.com</a></p>
</footer>
</body>
</html>

141
docs/inductor.html Normal file
View file

@ -0,0 +1,141 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag("consent","default",{ad_storage:"granted",ad_user_data:"granted",ad_personalization:"granted",analytics_storage:"granted"});gtag("consent","default",{ad_storage:"denied",ad_user_data:"denied",ad_personalization:"denied",analytics_storage:"denied",region:"AT,BE,BG,HR,CY,CZ,DK,EE,FI,FR,DE,GR,HU,IE,IT,LV,LT,LU,MT,NL,PL,PT,RO,SK,SI,ES,SE,IS,LI,NO,GB,CH,UA".split(",")});</script>
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-X1MKL6C4B0"></script>
<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag("js",new Date());gtag("config","G-X1MKL6C4B0");</script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Inductor Decoder</title>
<script src="tool-bridge.js"></script>
<script src="spyglass.js"></script>
<link rel="stylesheet" href="tool-style.css">
<style>
:root { --accent: #16a34a; --resistor-body: #86efac; --resistor-shadow: #4ade80; --bg: #f0fdf4; }
.controls-grid { grid-template-columns: 1fr 1fr 1fr; }
</style>
</head>
<body>
<div class="container">
<h1 id="app-title">Inductor Decoder</h1>
<div class="top-section">
<div id="spyglass-col" class="col-spyglass">
<div class="spyglass-wrapper"><canvas id="tool-canvas" class="spyglass-canvas" width="600" height="600"></canvas><div id="tool-zoom" class="zoom-badge">2x</div></div>
<div id="view-selector"></div>
</div>
<div class="col-controls">
<div class="reverse-input-group"><div class="input-row"><input type="text" id="nominal-input" class="inp-nominal" placeholder="e.g. 100n, 4u7"><button class="btn-action" onclick="applyNominal()">Set</button></div></div>
<div class="controls-grid">
<button class="btn-mode active" onclick="setBands(3)">3 Band</button>
<button class="btn-mode" onclick="setBands(4)">4 Band</button>
<button class="btn-mode" onclick="setBands(5)">5 Band (Mil)</button>
</div>
</div>
</div>
<div class="resistor-wrapper-outer"><div class="resistor-dumbbell-shape"><div class="bands-container" id="picker-area"></div></div></div>
<div id="results" class="result-box"></div>
<div class="app-footer">Copyright © 2025 Taras Greben — <a href="https://pcb.etaras.com">pcb.eTaras.com</a></div>
</div>
<div id="global-dropdown"></div>
<script>
const colors = [
{ name: 'Black', val: 0, mult: 1, tol: 20, css: 'c-bk' },
{ name: 'Brown', val: 1, mult: 10, tol: 1, css: 'c-bn' },
{ name: 'Red', val: 2, mult: 100, tol: 2, css: 'c-rd' },
{ name: 'Orange', val: 3, mult: 1000, tol: 3, css: 'c-og' },
{ name: 'Yellow', val: 4, mult: 10000, tol: 4, css: 'c-ye' },
{ name: 'Green', val: 5, mult: null, tol: null, css: 'c-gn' },
{ name: 'Blue', val: 6, mult: null, tol: null, css: 'c-bu' },
{ name: 'Violet', val: 7, mult: null, tol: null, css: 'c-vt' },
{ name: 'Gray', val: 8, mult: null, tol: null, css: 'c-gy' },
{ name: 'White', val: 9, mult: null, tol: null, css: 'c-wh' },
{ name: 'Gold', val: null, mult: 0.1, tol: 5, css: 'c-gd' },
{ name: 'Silver', val: null, mult: 0.01, tol: 10, css: 'c-sv' }
];
let currentBands=3; let bandState=[1,0,0]; let activeBandIndex=-1; let isEmbed=false; let bridge=null;
const globalDropdown = document.getElementById('global-dropdown');
function init() {
initGlobalDropdown();
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('embed') || window.self !== window.top) { isEmbed = true; document.getElementById('app-title').style.display = 'none'; }
setBands(3);
}
function initGlobalDropdown(){ globalDropdown.innerHTML=''; colors.forEach((c,i)=>{ const d=document.createElement('div'); d.className=`custom-option ${c.css}`; d.innerText=c.name; d.onclick=e=>{e.stopPropagation();if(activeBandIndex>-1){bandState[activeBandIndex]=i;renderPickers();calculate();closeDropdown();}}; globalDropdown.appendChild(d); }); }
function openGlobalDropdown(triggerElement) {
globalDropdown.style.display = 'flex'; globalDropdown.style.top = '0px'; globalDropdown.style.left = '0px';
globalDropdown.classList.remove('condensed', 'super-condensed');
globalDropdown.style.height = 'auto'; globalDropdown.style.overflowY = 'hidden';
const triggerRect = triggerElement.getBoundingClientRect();
const menuRect = globalDropdown.getBoundingClientRect();
const viewportHeight = window.innerHeight;
let top = triggerRect.bottom + 5;
let left = triggerRect.left + (triggerRect.width / 2);
let projectedBottom = top + menuRect.height;
if (projectedBottom > viewportHeight - 10) { globalDropdown.classList.add('condensed'); projectedBottom = top + globalDropdown.getBoundingClientRect().height; }
if (projectedBottom > viewportHeight - 10) { globalDropdown.classList.add('super-condensed'); projectedBottom = top + globalDropdown.getBoundingClientRect().height; }
if (projectedBottom > viewportHeight - 10) { const height = globalDropdown.getBoundingClientRect().height; top = viewportHeight - height - 10; if (top < 10) { top = 10; globalDropdown.style.height = (viewportHeight - 20) + 'px'; globalDropdown.style.overflowY = 'auto'; } }
globalDropdown.style.top = top + 'px'; globalDropdown.style.left = left + 'px'; globalDropdown.style.transform = 'translateX(-50%)';
}
window.onclick=closeDropdown; function closeDropdown(){globalDropdown.style.display='none';activeBandIndex=-1;}
function setBands(n){ currentBands=n; document.querySelectorAll('.btn-mode').forEach((b,i)=>b.classList.toggle('active',(i+3)===n)); if(n===3)bandState=[1,0,0];else if(n===4)bandState=[1,0,0,11];else bandState=[11,1,0,0,10]; renderPickers(); calculate(); }
function renderPickers(){ const a=document.getElementById('picker-area'); a.innerHTML=''; for(let i=0;i<currentBands;i++){ const w=document.createElement('div'); w.className='custom-select-wrapper'; const t=document.createElement('div'); t.className=`custom-select__trigger ${colors[bandState[i]].css}`; t.innerHTML=`<span>${colors[bandState[i]].name}</span>`; t.onclick=e=>{e.stopPropagation(); if(activeBandIndex===i&&globalDropdown.style.display==='flex')closeDropdown(); else{activeBandIndex=i;openGlobalDropdown(t);}}; w.appendChild(t); a.appendChild(w); } }
function parseInputStr(str) { if(!str)return null; str=str.toLowerCase().trim(); let m=1; if(str.includes('m')){m=1e3;str=str.replace('m','.');}else if(str.includes('u')){m=1;str=str.replace('u','.');}else if(str.includes('n')){m=1e-3;str=str.replace('n','.');} const v=parseFloat(str); return isNaN(v)?null:v*m; }
function applyNominal() { const raw=document.getElementById('nominal-input').value; const uH=parseInputStr(raw); if(uH===null)return; let val=uH; let exp=0; if(val<10){let s=(Math.round(val*1000)/1000).toString(); if(s.includes('.')){const sh=s.split('.')[1].length;val=Math.round(val*Math.pow(10,sh));exp=-sh;}} let s=val.toString(); const d1=parseInt(s[0]); const d2=parseInt(s.length>1?s[1]:0); const te=s.length-2+exp; let mi=-1; if(te>=0&&te<=4)mi=te;else if(te===-1)mi=10;else if(te===-2)mi=11; if(mi!==-1){ if(currentBands===3)bandState=[d1,d2,mi];else if(currentBands===4)bandState=[d1,d2,mi,11];else bandState=[11,d1,d2,mi,10]; renderPickers(); calculate(); } }
function decode(idxs) {
if (currentBands !== 5 && colors[idxs[0]].name === 'Silver') return { valid: false };
let v=0, m=0, t=0;
if (currentBands === 3) {
v = colors[idxs[0]].val * 10 + colors[idxs[1]].val; m = colors[idxs[2]].mult; t = 20;
} else if (currentBands === 4) {
v = colors[idxs[0]].val * 10 + colors[idxs[1]].val; m = colors[idxs[2]].mult; t = colors[idxs[3]].tol;
} else {
if (colors[idxs[0]].name !== 'Silver') return { valid: false };
v = colors[idxs[1]].val * 10 + colors[idxs[2]].val; m = colors[idxs[3]].mult; t = colors[idxs[4]].tol;
}
if (isNaN(v) || m === null || t === null) return { valid: false };
return { uH: v * m, tol: t, valid: true };
}
function format(uH){ if(uH>=1000)return(uH/1000).toFixed(2)+'mH'; if(uH<1)return(uH*1000).toFixed(0)+'nH'; return uH.toFixed(2)+'uH'; }
function calculate(){
const f=decode(bandState.slice(0,currentBands)); const b=decode([...bandState.slice(0,currentBands)].reverse());
const arrowSvg = (isF) => isF ? `<svg viewBox="0 0 24 24"><path d="M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z"></path></svg>` : `<svg viewBox="0 0 24 24"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"></path></svg>`;
const h=(d,fwd)=>{
const btn = isEmbed ? `<button class="btn-select" onclick="bridge.sendResult('${format(d.uH)}')">Select</button>` : '';
const badge = fwd ? `<span class="badge-success" style="margin-left:0.5rem; padding:0.1rem 0.5rem; font-size:0.7rem; vertical-align:middle;">Likely</span>` : '';
return `<div class="result-row"><div class="${fwd?'direction-icon':'direction-icon reverse'}">${arrowSvg(fwd)}</div><div class="val-group"><span class="val-text">${format(d.uH)}</span><span class="meta-text">±${d.tol}%${badge}</span></div>${btn}</div>`;
};
let res=''; if(f.valid)res+=h(f,true); document.getElementById('results').innerHTML=res||'<div style="text-align:center;color:red;padding:1rem">Invalid</div>';
}
function parseInductorDescription(desc) {
let n=3, t=-1; if(desc.includes("4-Band"))n=4; else if(desc.includes("5-Band"))n=5;
const tm=desc.match(/±\s*([\d.]+)%/); if(tm){const v=parseFloat(tm[1]);const c=colors.findIndex(x=>x.tol===v);if(c!==-1)t=c;}
setBands(n); if(t!==-1){ if(n===4)bandState[3]=t; if(n===5)bandState[4]=t; }
}
init();
bridge = new ToolBridge({
onData: (d) => {
if(d.value) document.getElementById('nominal-input').value = d.value;
if(d.description) parseInductorDescription(d.description);
applyNominal();
}
});
</script>
</body>
</html>

539
docs/inspector.js Normal file
View file

@ -0,0 +1,539 @@
/* inspector.js - Visual Trace Tracking (v6) */
class Inspector {
constructor(db, cv) {
this.db = db;
this.cv = cv;
this.grid = document.getElementById('inspect-grid');
this.sidebarList = document.getElementById('inspect-layers');
this.activeNetEl = document.getElementById('inspect-active-net');
this.viewers = {};
this.visibleIds = new Set();
this.activeNet = null;
this.masterId = null;
this.netNodeCache = {}; // Cache for calculated node positions
}
async init() {
this.sidebarList.innerHTML = '';
// Hide the "+ New Net" button for now
// We find it by the onclick attribute since it doesn't have an ID in the HTML
const newNetBtn = document.querySelector('button[onclick="inspector.startNewNet()"]');
if(newNetBtn) newNetBtn.style.display = 'none';
const imgs = [...bomImages].sort((a,b) => {
const nA = a.name.toLowerCase(), nB = b.name.toLowerCase();
if(nA.includes('top')) return -1;
if(nB.includes('top')) return 1;
return nA.localeCompare(nB);
});
// Checkbox Layout
imgs.forEach(img => {
const row = document.createElement('div');
// Force Grid layout for strict alignment of checkbox vs label
row.style.cssText = "display:grid; grid-template-columns: 20px 1fr; align-items:center; gap:5px; color:#334155; font-size:0.85rem; border-bottom:1px solid #f1f5f9; padding-bottom:4px;";
const chk = document.createElement('input');
chk.type = 'checkbox';
chk.checked = this.visibleIds.has(img.id);
chk.onchange = () => this.toggleLayer(img.id, chk.checked);
const label = document.createElement('span');
label.innerText = img.name;
label.style.cssText = "white-space:nowrap; overflow:hidden; text-overflow:ellipsis;";
label.title = img.name;
row.appendChild(chk);
row.appendChild(label);
this.sidebarList.appendChild(row);
});
// Smart Default Selection
if(this.visibleIds.size === 0) {
const isDesktop = window.matchMedia("(min-width: 800px)").matches;
if (isDesktop) {
imgs.forEach(img => this.visibleIds.add(img.id));
} else {
if (imgs.length > 0) this.visibleIds.add(imgs[0].id);
if (imgs.length > 1) this.visibleIds.add(imgs[1].id);
}
// Sync UI
Array.from(this.sidebarList.querySelectorAll('input')).forEach((chk, i) => {
chk.checked = this.visibleIds.has(imgs[i].id);
});
}
this.updateNetUI();
this.renderGrid();
}
async renderGrid() {
this.grid.innerHTML = '';
this.viewers = {};
if(this.visibleIds.size === 0) {
this.grid.innerHTML = '<div style="display:flex; align-items:center; justify-content:center; color:#64748b; height:100%;">Select layers to inspect</div>';
return;
}
// [FIX] Force Grid Layout Styles via JS to ensure they apply
this.grid.style.display = 'grid';
this.grid.style.width = '100%';
this.grid.style.height = '100%';
this.grid.style.boxSizing = 'border-box';
this.grid.style.gap = '2px';
this.grid.style.background = '#000';
const count = this.visibleIds.size;
this.grid.style.gridTemplateColumns = (count > 1) ? '1fr 1fr' : '1fr';
this.grid.style.gridTemplateRows = (count > 2) ? '1fr 1fr' : '1fr';
if(!this.masterId || !this.visibleIds.has(this.masterId)) {
this.masterId = this.visibleIds.values().next().value;
}
for(const id of this.visibleIds) {
const imgRec = bomImages.find(i => i.id === id);
if(!imgRec) continue;
const cell = document.createElement('div');
cell.style.cssText = "position:relative; overflow:hidden; border:1px solid #334155; background:#000; width:100%; height:100%;";
const cvs = document.createElement('canvas');
cvs.id = `inspect-cvs-${id}`;
cvs.style.display = 'block';
cell.appendChild(cvs);
const lbl = document.createElement('div');
lbl.innerText = imgRec.name;
lbl.style.cssText = "position:absolute; top:5px; left:5px; background:rgba(0,0,0,0.7); padding:2px 6px; font-size:0.7rem; pointer-events:none; border-radius:3px; color:white;";
cell.appendChild(lbl);
this.grid.appendChild(cell);
const viewer = new PanZoomCanvas(cvs.id,
(ctx, k) => this.drawOverlay(id, ctx, k),
(x, y) => this.handleNodeClick(id, x, y),
(dx, dy, mode) => {
// Hit Test: Return -1 to allow Panning
if(mode === 'check') return -1;
}
);
viewer.onMouseMove = (x, y) => {
if(this.masterId === id) this.syncCursors(id, x, y);
};
// Activation & Click Handling
let downX = 0, downY = 0;
let wasActiveBeforeDown = false; // Track state before click
cvs.addEventListener('mousedown', (e) => {
if (e.button === 0) { // Left Click
downX = e.clientX;
downY = e.clientY;
// Check if we are clicking an already active window
wasActiveBeforeDown = (this.masterId === id);
// Activate immediately to allow dragging/tracking
if(this.masterId !== id) {
this.masterId = id;
}
const pt = viewer.getImgCoords(e.clientX, e.clientY);
this.syncCursors(id, pt.x, pt.y);
}
});
// Right Click: Inactivate (Stop Tracking)
cvs.oncontextmenu = (e) => {
e.preventDefault();
e.stopPropagation();
this.masterId = null;
this.cursorState = null;
// Clear cursors on all views
Object.values(this.viewers).forEach(v => {
v.cursorPos = null;
v.setDimmed(false);
v.draw();
});
};
cvs.addEventListener('mouseup', (e) => {
if (e.button !== 0) return; // Left click only
const dist = Math.hypot(e.clientX - downX, e.clientY - downY);
if (dist < 10) { // Click vs Drag
// Only Add/Edit node if the window was ALREADY active.
// If it wasn't, we just activated it in mousedown, so do nothing else.
if (wasActiveBeforeDown) {
const pt = viewer.getImgCoords(e.clientX, e.clientY);
// 1. Try to edit an existing node
const hit = this.handleNodeClick(id, pt.x, pt.y);
// 2. If no node hit, try to add a new one
if (!hit) {
this.handleAddNode(id, pt.x, pt.y);
}
}
}
});
this.viewers[id] = viewer;
try {
const bmp = await createImageBitmap(imgRec.blob);
viewer.setImage(bmp);
// Force sync canvas resolution to container size before fitting
if (cell.clientWidth && cell.clientHeight) {
viewer.canvas.width = cell.clientWidth;
viewer.canvas.height = cell.clientHeight;
}
viewer.fit();
if(imgRec.name.toLowerCase().includes('bot') && !imgRec.name.toLowerCase().includes('top')) {
viewer.setMirror(true);
}
} catch(e) { console.error("Inspector img load error", e); }
}
this.updateNetNodeCache();
}
handleNodeClick(imgId, x, y) {
if (!this.activeNet || !this.netNodeCache[imgId]) return false;
const viewer = this.viewers[imgId];
if (!viewer) return false;
// Hit test in Screen Space
const HIT_RADIUS = 20;
const hit = this.netNodeCache[imgId].find(n => {
const dist = Math.hypot(n.x - x, n.y - y);
return (dist * viewer.t.k) < HIT_RADIUS;
});
if (hit) {
const oldLabel = hit.label;
const newLabel = prompt(`Edit Node "${oldLabel}"\n\nEnter new name to Rename.\nClear text and click OK to Delete.`, oldLabel);
if (newLabel !== null) {
const nodeIndex = this.activeNet.nodes.indexOf(hit.origNode);
if (nodeIndex === -1) return true;
if (newLabel.trim() === "") {
this.activeNet.nodes.splice(nodeIndex, 1); // Delete
} else {
this.activeNet.nodes[nodeIndex].label = newLabel.trim(); // Rename
}
this.updateNetUI();
}
return true; // Handled
}
return false; // Not handled
}
toggleLayer(id, isVisible) {
if(isVisible) this.visibleIds.add(id);
else this.visibleIds.delete(id);
this.renderGrid();
}
// Load an existing net for editing
loadNet(net) {
// Deep copy to ensure "Cancel" works (discarding changes)
this.activeNet = JSON.parse(JSON.stringify(net));
this.updateNetUI();
// Ensure pins are visible immediately
// We might need to ensure grid is rendered if not already
if(Object.keys(this.viewers).length === 0) {
this.renderGrid().then(() => {
Object.values(this.viewers).forEach(v => v.draw());
});
} else {
Object.values(this.viewers).forEach(v => v.draw());
}
}
async updateNetNodeCache() {
this.netNodeCache = {};
if (!this.activeNet || !this.activeNet.nodes) return;
// Initialize cache arrays for visible viewers
for (const vid of this.visibleIds) this.netNodeCache[vid] = [];
// Process each node in the net
for (const node of this.activeNet.nodes) {
// 1. Add directly to the source image (Blue)
if (this.visibleIds.has(node.imgId)) {
this.netNodeCache[node.imgId].push({
x: node.x, y: node.y, label: node.label,
color: '#2563eb', isSource: true, origNode: node
});
}
// 2. Project to other visible images (Green)
// We need paths from the node's image to all other visible images
const paths = await ImageGraph.solvePaths(node.imgId, this.cv, this.db);
for (const p of paths) {
if (this.visibleIds.has(p.id)) {
const proj = this.cv.projectPoint(node.x, node.y, p.H);
if (proj) {
this.netNodeCache[p.id].push({
x: proj.x, y: proj.y, label: node.label,
color: '#4ade80', isSource: false, origNode: node
});
}
}
}
}
// Redraw all to show changes
Object.values(this.viewers).forEach(v => v.draw());
}
async syncCursors(masterId, mx, my) {
this.cursorState = { masterId, mx, my };
for(const [id, viewer] of Object.entries(this.viewers)) {
if(id === masterId) {
viewer.setDimmed(false);
viewer.draw();
continue;
}
const path = await ImageGraph.solvePaths(masterId, this.cv, this.db);
const targetPath = path.find(p => p.id === id);
if(targetPath) {
const pt = this.cv.projectPoint(mx, my, targetPath.H);
if(pt) {
viewer.cursorPos = pt;
const w = viewer.bmp ? viewer.bmp.width : 1000;
const h = viewer.bmp ? viewer.bmp.height : 1000;
const inside = (pt.x >= 0 && pt.y >= 0 && pt.x <= w && pt.y <= h);
viewer.setDimmed(!inside);
if (inside && viewer.bmp) {
const k = viewer.t.k;
const tx = viewer.t.x;
const ty = viewer.t.y;
const imgX = viewer.isMirrored ? (w - pt.x) : pt.x;
const screenX = imgX * k + tx;
const screenY = pt.y * k + ty;
const cvsW = viewer.canvas.width;
const cvsH = viewer.canvas.height;
const padX = cvsW * 0.25;
const padY = cvsH * 0.25;
let dx = 0; let dy = 0;
if (screenX > cvsW - padX) dx = (cvsW - padX) - screenX;
else if (screenX < padX) dx = padX - screenX;
if (screenY > cvsH - padY) dy = (cvsH - padY) - screenY;
else if (screenY < padY) dy = padY - screenY;
if (dx !== 0 || dy !== 0) {
viewer.t.x += dx;
viewer.t.y += dy;
}
}
} else {
viewer.cursorPos = null;
viewer.setDimmed(true);
}
} else {
viewer.cursorPos = null;
viewer.setDimmed(true);
}
viewer.draw();
}
}
drawOverlay(id, ctx, k) {
const viewer = this.viewers[id];
if (!viewer) return;
const ik = 1/k; // Inverse zoom factor
// 1. Draw Cached Nodes as Pins
if (this.netNodeCache[id]) {
this.netNodeCache[id].forEach(n => {
let drawX = n.x;
if (viewer.isMirrored && viewer.bmp) drawX = viewer.bmp.width - n.x;
ctx.save();
ctx.translate(drawX, n.y);
ctx.scale(ik, ik); // Keep pin constant size on screen
// ROTATION: -45 degrees (Counter-Clockwise)
// This aligns the "Bottom-Left" corner of our square to point straight down if the square is in the Top-Right quadrant.
ctx.rotate(-Math.PI / 4);
// DRAW PIN SHAPE
// We draw a square relative to the origin (0,0).
// The Origin (0,0) is the Sharp Tip.
// The square extends into x>0, y<0 (Visual Top-Right relative to rotation axis)
// so that when rotated -45deg, it stands "Up" above the point.
const s = 20; // Size of the square side
const r = 10; // Radius (50% of size)
ctx.beginPath();
ctx.moveTo(0, 0); // Tip starts exactly on the node coordinate
// Left Edge (going 'Up' in local coords) -> Top-Left Corner
ctx.lineTo(0, -s + r);
ctx.arcTo(0, -s, s, -s, r);
// Top Edge -> Top-Right Corner
ctx.lineTo(s - r, -s);
ctx.arcTo(s, -s, s, 0, r);
// Right Edge -> Bottom-Right Corner
ctx.lineTo(s, -r);
ctx.arcTo(s, 0, 0, 0, r);
// Return to Tip
ctx.lineTo(0, 0);
ctx.closePath();
ctx.fillStyle = n.color; // Blue (#2563eb) or Green (#4ade80)
ctx.fill();
// White Border
ctx.lineWidth = 1.5;
ctx.strokeStyle = 'white';
ctx.stroke();
// LABEL
// We want the text in the center of the "bulb".
// The center of our square is at (s/2, -s/2).
ctx.translate(s/2, -s/2);
// Rotate text back +45deg so it appears horizontal
ctx.rotate(Math.PI / 4);
ctx.fillStyle = 'white';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = 'bold 9px sans-serif';
ctx.fillText(n.label, 0, 0);
ctx.restore();
});
}
// 2. Cursor Crosshair (Unchanged)
let cx, cy, color = '#ff0000';
if(this.cursorState && this.cursorState.masterId === id) {
cx = this.cursorState.mx; cy = this.cursorState.my;
if(viewer.isMirrored && viewer.bmp) cx = viewer.bmp.width - cx;
} else if (viewer.cursorPos) {
cx = viewer.cursorPos.x; cy = viewer.cursorPos.y;
if(viewer.isMirrored && viewer.bmp) cx = viewer.bmp.width - cx;
color = '#facc15';
}
if(cx !== undefined) {
const len = 100000;
ctx.lineWidth = 1 * ik;
ctx.strokeStyle = color;
ctx.beginPath();
ctx.moveTo(cx - len, cy); ctx.lineTo(cx + len, cy);
ctx.moveTo(cx, cy - len); ctx.lineTo(cx, cy + len);
ctx.stroke();
}
}
startNewNet() {
this.activeNet = { id: uuid(), name: "New Net", nodes: [], isNew: true };
this.updateNetUI();
}
handleAddNode(imgId, x, y) {
// Determine default label (start at P1 if no net exists)
const nextIdx = this.activeNet ? this.activeNet.nodes.length + 1 : 1;
const defaultLabel = `P${nextIdx}`;
const label = prompt("Pad/Pin Name:", defaultLabel);
if(label) {
if(!this.activeNet) {
this.startNewNet();
}
this.activeNet.nodes.push({
id: uuid(),
imgId: imgId,
x: Math.round(x),
y: Math.round(y),
label: label
});
this.updateNetUI();
Object.values(this.viewers).forEach(v => v.draw());
}
}
async saveNet() {
if(!this.activeNet) return;
// Only prompt for name if it's a new net
if (this.activeNet.isNew) {
const name = prompt("Net Name:", this.activeNet.name);
if(name) {
this.activeNet.name = name;
delete this.activeNet.isNew; // Remove flag before saving
} else {
return; // Cancelled by user in prompt
}
}
this.activeNet.projectId = currentBomId;
await this.db.addNet(this.activeNet);
this.activeNet = null;
this.updateNetUI();
if(window.netManager) window.netManager.render();
// Return to Nets list
history.back();
}
cancelNet() {
this.activeNet = null;
this.updateNetUI();
history.back();
}
updateNetUI() {
if(!this.activeNet) {
this.activeNetEl.style.display = 'none';
} else {
this.activeNetEl.style.cssText = "pointer-events:auto; background:rgba(15, 23, 42, 0.9); padding:4px 10px; border-radius:20px; border:1px solid #334155; display:flex; color:white; align-items:center; gap:8px; box-shadow:0 4px 6px rgba(0,0,0,0.2); backdrop-filter:blur(4px); font-size:0.85rem; height:auto;";
this.activeNetEl.innerHTML = `
<span style="font-weight:600; color:#4ade80; max-width:100px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${this.activeNet.name}</span>
<span style="color:#94a3b8; border-left:1px solid #475569; padding-left:8px; font-size:0.8rem;">${this.activeNet.nodes.length}</span>
<button class="primary sm-btn" style="padding:1px 8px; font-size:0.75rem; height:24px; min-height:0; line-height:1;" onclick="inspector.saveNet()">Save</button>
<button class="danger sm-btn" style="padding:0; width:20px; height:20px; min-height:0; border-radius:50%; line-height:1; display:flex; align-items:center; justify-content:center;" onclick="inspector.cancelNet()">×</button>
`;
}
this.updateNetNodeCache();
}
}

192
docs/nets.js Normal file
View file

@ -0,0 +1,192 @@
/* nets.js - Netlist Management (v2) */
class NetManager {
constructor(db) {
this.db = db;
}
async render() {
const tbody = document.getElementById('nets-body');
if(!tbody) return;
tbody.innerHTML = '<tr><td colspan="3" style="text-align:center; color:#888;">Loading...</td></tr>';
const nets = await this.db.getNets();
tbody.innerHTML = '';
if(nets.length === 0) {
tbody.innerHTML = '<tr><td colspan="3" style="text-align:center; padding:2rem; color:#94a3b8;">No nets defined. Go to "Inspect" to create one.</td></tr>';
return;
}
nets.forEach(net => {
const tr = document.createElement('tr');
tr.style.height = 'auto';
tr.style.minHeight = '2.2rem';
// Target Icon for Editing
const targetIcon = `<span style="cursor:pointer; margin-right:0.5rem;" onclick="netManager.editNet('${net.id}')" title="Edit Net on Board">🎯</span>`;
let nodesHtml = '';
net.nodes.forEach((n, idx) => {
nodesHtml += `<span class="net-chip" onclick="netManager.editNode('${net.id}', ${idx})" title="Edit Node">${n.label}</span>`;
});
tr.innerHTML = `
<td style="display:flex; align-items:center; vertical-align:top; height:auto;">
${targetIcon}
<input type="text" value="${net.name}" onchange="netManager.rename('${net.id}', this.value)" style="border:none; background:transparent; font-weight:bold; flex:1; min-width:0;">
</td>
<!-- [FIX] Allow cell to expand freely -->
<td style="white-space:normal; height:auto; overflow:visible;">
<div style="display:flex; flex-wrap:wrap; gap:4px; padding:2px 0;">${nodesHtml}</div>
</td>
<td style="text-align:right; vertical-align:top; height:auto;">
<button class="danger sm-btn" onclick="netManager.delete('${net.id}')" title="Delete Net">🗑️</button>
</td>
`;
tbody.appendChild(tr);
});
}
// Edit Net in Inspector
async editNet(id) {
const net = await this.db._tx('nets', 'readonly', s => s.get(id));
if(net) {
switchView('inspect');
// We use the global inspector instance
if(window.inspector) window.inspector.loadNet(net);
}
}
// Edit individual node
async editNode(netId, nodeIdx) {
const net = await this.db._tx('nets', 'readonly', s => s.get(netId));
if(!net || !net.nodes[nodeIdx]) return;
const node = net.nodes[nodeIdx];
// Simple action menu via Prompt/Confirm
// 1. Ask for new name (Empty = Delete)
const newName = prompt(`Edit node "${node.label}"\n\nEnter new name to Rename.\nClear text and press OK to Delete.`, node.label);
if(newName === null) return; // Cancelled
if(newName.trim() === '') {
if(confirm("Delete this node?")) {
net.nodes.splice(nodeIdx, 1);
await this.db.addNet(net);
this.render();
}
} else {
net.nodes[nodeIdx].label = newName.trim();
await this.db.addNet(net);
this.render();
}
}
async rename(id, newName) {
if(!newName.trim()) return;
const net = await this.db._tx('nets', 'readonly', s => s.get(id));
if(net) {
net.name = newName.trim();
await this.db.addNet(net);
}
}
async delete(id) {
if(await confirmAction("Delete this net?", "Delete")) {
await this.db.deleteNet(id);
this.render();
}
}
async exportKiCad() {
// 1. Determine Filename
let filename = "board_netlist";
if (typeof currentBomId !== 'undefined' && typeof bomList !== 'undefined') {
const meta = bomList.find(b => b.id === currentBomId);
if (meta && meta.name) {
filename = meta.name.replace(/[^a-z0-9_\-\.]/gi, '_');
}
}
const nets = await this.db.getNets();
const components = (typeof bomData !== 'undefined') ? bomData : [];
// --- CONFIGURATION: Component Type Mapping ---
// We only map types that are structurally unambiguous (2-pin passives, test points).
// Complex types (Q, U, J) are commented out to prevent incorrect symbol assignment.
const COMPONENT_LIBRARY_MAP = {
'R': { lib: "Device", part: "R", desc: "Resistor" },
'C': { lib: "Device", part: "C", desc: "Unpolarized capacitor" },
'L': { lib: "Device", part: "L", desc: "Inductor" },
'D': { lib: "Device", part: "D", desc: "Diode" },
'TP': { lib: "Connector", part: "TestPoint", desc: "Test Point" },
// --- AMBIGUOUS TYPES (Disabled by default) ---
// 'Q': { lib: "Device", part: "Q_NPN_BEC", desc: "Transistor NPN" }, // Risk: Could be PNP, MOSFET, IGBT
// 'J': { lib: "Connector", part: "Conn_01x02_Male", desc: "Connector" }, // Risk: Pin count unknown
// 'CN': { lib: "Connector", part: "Conn_01x02_Male", desc: "Connector" }, // Risk: Pin count unknown
};
let out = "(export (version D)\n";
// 2. Export Components
out += " (components\n";
components.forEach(c => {
const val = c.value ? c.value : "~";
const footprint = c.desc ? c.desc.replace(/"/g, '') : "";
const tstamp = c.id ? c.id.substring(0, 8) : Math.floor(Math.random()*10000000).toString(16);
// Detect Type from Prefix
const prefix = (c.label.match(/^[A-Z]+/) || [""])[0].toUpperCase();
// Lookup Library definition
// Future TODO: Add logic here to check c.desc for keywords like "NPN", "MOSFET", etc.
const def = COMPONENT_LIBRARY_MAP[prefix];
out += ` (comp (ref "${c.label}")\n`;
out += ` (value "${val}")\n`;
if(footprint) out += ` (footprint "${footprint}")\n`;
// Inject Library Source if we have a safe definition
if(def) {
out += ` (libsource (lib "${def.lib}") (part "${def.part}") (description "${def.desc}"))\n`;
out += ` (property (name "Sheetname") (value "")) (property (name "Sheetfile") (value "${filename}.kicad_sch"))\n`;
}
out += ` (tstamp "${tstamp}")\n`;
out += ` )\n`;
});
out += " )\n";
// 3. Export Nets
out += " (nets\n";
nets.forEach((net, i) => {
out += ` (net (code ${i+1}) (name "${net.name}")\n`;
net.nodes.forEach(node => {
const parts = node.label.split('.');
if(parts.length === 2) {
// Format: R1.2 (Ref R1, Pin 2)
out += ` (node (ref "${parts[0]}") (pin "${parts[1]}"))\n`;
} else {
// Fallback: TestPoints or direct names often use Pin 1
out += ` (node (ref "${node.label}") (pin "1"))\n`;
}
});
out += " )\n";
});
out += " )\n)\n";
// 4. Download
const blob = new Blob([out], { type: 'text/plain' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `${filename}.net`;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
}
}

227
docs/resistor.html Normal file
View file

@ -0,0 +1,227 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag("consent","default",{ad_storage:"granted",ad_user_data:"granted",ad_personalization:"granted",analytics_storage:"granted"});gtag("consent","default",{ad_storage:"denied",ad_user_data:"denied",ad_personalization:"denied",analytics_storage:"denied",region:"AT,BE,BG,HR,CY,CZ,DK,EE,FI,FR,DE,GR,HU,IE,IT,LV,LT,LU,MT,NL,PL,PT,RO,SK,SI,ES,SE,IS,LI,NO,GB,CH,UA".split(",")});</script>
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-X1MKL6C4B0"></script>
<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag("js",new Date());gtag("config","G-X1MKL6C4B0");</script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Resistor Color Decoder</title>
<script src="tool-bridge.js"></script>
<script src="spyglass.js"></script>
<link rel="stylesheet" href="tool-style.css">
<style>
:root { --accent: #2563eb; --resistor-body: #82cae0; --resistor-shadow: #5ea8bd; }
.controls-grid { grid-template-columns: 1fr 1fr; }
</style>
</head>
<body>
<div class="container">
<h1 id="app-title">Resistor Color Decoder</h1>
<div class="top-section">
<div id="spyglass-col" class="col-spyglass">
<div class="spyglass-wrapper">
<canvas id="tool-canvas" class="spyglass-canvas" width="600" height="600"></canvas>
<div id="tool-zoom" class="zoom-badge">2x</div>
</div>
<div id="view-selector"></div>
</div>
<div class="col-controls">
<div class="reverse-input-group">
<div class="input-row">
<input type="text" id="nominal-input" class="inp-nominal" placeholder="e.g. 4k7, 100">
<button class="btn-action" onclick="applyNominal()">Set</button>
</div>
</div>
<div class="controls-grid">
<button class="btn-mode" onclick="setBands(3)">3 Band</button>
<button class="btn-mode" onclick="setBands(4)">4 Band</button>
<button class="btn-mode" onclick="setBands(5)">5 Band</button>
<button class="btn-mode active" onclick="setBands(6)">6 Band</button>
</div>
</div>
</div>
<div class="resistor-wrapper-outer">
<div class="resistor-dumbbell-shape">
<div class="bands-container" id="picker-area"></div>
</div>
</div>
<div id="results" class="result-box"></div>
<div class="app-footer">Copyright © 2025 Taras Greben — <a href="https://pcb.etaras.com">pcb.eTaras.com</a></div>
</div>
<div id="global-dropdown"></div>
<script>
const colors = [
{ name: 'Black', val: 0, mult: 1, tol: null, tcr: 250, css: 'c-bk' },
{ name: 'Brown', val: 1, mult: 10, tol: 1, tcr: 100, css: 'c-bn' },
{ name: 'Red', val: 2, mult: 100, tol: 2, tcr: 50, css: 'c-rd' },
{ name: 'Orange', val: 3, mult: 1000, tol: null, tcr: 15, css: 'c-og' },
{ name: 'Yellow', val: 4, mult: 10000, tol: null, tcr: 25, css: 'c-ye' },
{ name: 'Green', val: 5, mult: 100000, tol: 0.5, tcr: 20, css: 'c-gn' },
{ name: 'Blue', val: 6, mult: 1000000, tol: 0.25, tcr: 10, css: 'c-bu' },
{ name: 'Violet', val: 7, mult: 10000000, tol: 0.1, tcr: 5, css: 'c-vt' },
{ name: 'Gray', val: 8, mult: null, tol: 0.05, tcr: 1, css: 'c-gy' },
{ name: 'White', val: 9, mult: null, tol: null, tcr: null, css: 'c-wh' },
{ name: 'Gold', val: null, mult: 0.1, tol: 5, tcr: null, css: 'c-gd' },
{ name: 'Silver', val: null, mult: 0.01, tol: 10, tcr: null, css: 'c-sv' }
];
const e12=[1.0,1.2,1.5,1.8,2.2,2.7,3.3,3.9,4.7,5.6,6.8,8.2];
const e24=[1.0,1.1,1.2,1.3,1.5,1.6,1.8,2.0,2.2,2.4,2.7,3.0,3.3,3.6,3.9,4.3,4.7,5.1,5.6,6.2,6.8,7.5,8.2,9.1];
let currentBands=4; let bandState=[1,2,2,10,1,2]; let activeBandIndex=-1; let isEmbed=false; let bridge=null;
const globalDropdown = document.getElementById('global-dropdown');
function init() {
initGlobalDropdown();
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('embed') || window.self !== window.top) {
isEmbed = true;
document.getElementById('app-title').style.display = 'none';
}
setBands(4);
}
function initGlobalDropdown(){
globalDropdown.innerHTML='';
colors.forEach((c,i)=>{
const o=document.createElement('div');
o.className=`custom-option ${c.css}`;
o.innerText=c.name;
o.onclick=e=>{e.stopPropagation(); if(activeBandIndex>-1){bandState[activeBandIndex]=i;renderPickers();calculate();closeDropdown();}};
globalDropdown.appendChild(o);
});
}
function openGlobalDropdown(triggerElement) {
globalDropdown.style.display = 'flex'; globalDropdown.style.top = '0px'; globalDropdown.style.left = '0px';
globalDropdown.classList.remove('condensed', 'super-condensed');
globalDropdown.style.height = 'auto'; globalDropdown.style.overflowY = 'hidden';
const triggerRect = triggerElement.getBoundingClientRect();
const menuRect = globalDropdown.getBoundingClientRect();
const viewportHeight = window.innerHeight;
let top = triggerRect.bottom + 5;
let left = triggerRect.left + (triggerRect.width / 2);
let projectedBottom = top + menuRect.height;
if (projectedBottom > viewportHeight - 10) {
globalDropdown.classList.add('condensed');
projectedBottom = top + globalDropdown.getBoundingClientRect().height;
}
if (projectedBottom > viewportHeight - 10) {
globalDropdown.classList.add('super-condensed');
projectedBottom = top + globalDropdown.getBoundingClientRect().height;
}
if (projectedBottom > viewportHeight - 10) {
const height = globalDropdown.getBoundingClientRect().height;
top = viewportHeight - height - 10;
if (top < 10) {
top = 10;
globalDropdown.style.height = (viewportHeight - 20) + 'px';
globalDropdown.style.overflowY = 'auto';
}
}
globalDropdown.style.top = top + 'px';
globalDropdown.style.left = left + 'px';
globalDropdown.style.transform = 'translateX(-50%)';
}
function closeDropdown(){ globalDropdown.style.display='none'; activeBandIndex=-1; }
window.addEventListener('click', closeDropdown);
window.addEventListener('resize', closeDropdown);
window.addEventListener('scroll', closeDropdown, true);
function setBands(n){ currentBands=n; document.querySelectorAll('.btn-mode').forEach((b,i)=>b.classList.toggle('active',(i+3)===n)); while(bandState.length<n)bandState.push(0); renderPickers(); calculate(); }
function renderPickers(){
const a=document.getElementById('picker-area'); a.innerHTML='';
for(let i=0;i<currentBands;i++){
const w=document.createElement('div'); w.className='custom-select-wrapper';
const t=document.createElement('div'); t.className=`custom-select__trigger ${colors[bandState[i]].css}`;
t.innerHTML=`<span>${colors[bandState[i]].name}</span>`;
t.onclick=e=>{e.stopPropagation(); if(activeBandIndex===i&&globalDropdown.style.display==='flex')closeDropdown();else{activeBandIndex=i;openGlobalDropdown(t);}};
w.appendChild(t); a.appendChild(w);
}
}
function parseInputStr(str) { if(!str)return null; let m=1; if(str.includes('M')){m=1e6;str=str.replace('M','.');}else if(str.includes('m')){m=1e-3;str=str.replace('m','.');}else if(str.toLowerCase().includes('k')){m=1e3;str=str.replace(/k/i,'.');}else if(str.toLowerCase().includes('r')){m=1;str=str.replace(/r/i,'.');} const v=parseFloat(str); return isNaN(v)?null:v*m; }
function applyNominal(){ const raw=document.getElementById('nominal-input').value; const ohms=parseInputStr(raw); if(ohms===null)return; const nD=(currentBands>=5)?3:2; let val=ohms; const mag=Math.floor(Math.log10(val)); const shift=(nD-1)-mag; let norm=Math.round(val*Math.pow(10,shift)); let s=norm.toString(); if(s.length>nD){norm=Math.round(norm/10);s=norm.toString();} const d=[]; for(let i=0;i<nD;i++)d.push(parseInt(s[i])); const exp=Math.round(Math.log10(ohms/norm)); let mIdx=-1; if(exp>=0&&exp<=9)mIdx=exp;else if(exp===-1)mIdx=10;else if(exp===-2)mIdx=11; if(mIdx!==-1){ bandState[0]=d[0];bandState[1]=d[1]; if(nD===2){bandState[2]=mIdx;if(currentBands===4)bandState[3]=10;}else{bandState[2]=d[2];bandState[3]=mIdx;bandState[4]=1;if(currentBands===6)bandState[5]=1;} renderPickers(); calculate(); } }
function formatOhmsUI(v){ if(v>=1e6)return(v/1e6).toFixed(2)+' MΩ'; if(v>=1e3)return(v/1e3).toFixed(2)+' kΩ'; if(v<1)return(v*1000).toFixed(0)+' mΩ'; return v.toFixed(2)+' Ω'; }
function formatOhmsBOM(v){ if(v>=1e6)return(v/1e6).toFixed(2)+'M'; if(v>=1e3)return(v/1e3).toFixed(2)+'k'; if(v<1)return(v*1000).toFixed(0)+'m'; return v.toFixed(2)+'R'; }
function checkSeries(v){ let n=v; while(n>=10)n/=10; while(n<1&&n>0)n*=10; n=Math.round(n*100)/100; if(e12.includes(n))return "E12"; if(e24.includes(n))return "E24"; return ""; }
function decode(idx){ if(colors[idx[0]].name==='Black'||colors[idx[0]].name==='Gold'||colors[idx[0]].name==='Silver')return{valid:false}; let v=0,m=0,t=0,tcr=0; if(currentBands===3){v=colors[idx[0]].val*10+colors[idx[1]].val;m=colors[idx[2]].mult;t=20;}else if(currentBands===4){v=colors[idx[0]].val*10+colors[idx[1]].val;m=colors[idx[2]].mult;t=colors[idx[3]].tol;}else{v=colors[idx[0]].val*100+colors[idx[1]].val*10+colors[idx[2]].val;m=colors[idx[3]].mult;t=colors[idx[4]].tol;if(currentBands===6)tcr=colors[idx[5]].tcr;} if(m===null||(currentBands>3&&t===null))return{valid:false}; const o=v*m; return{ohms:o,tol:t,tcr,valid:true,seriesInfo:checkSeries(o)}; }
function calculate(){
const f=decode(bandState.slice(0,currentBands)); const b=decode([...bandState.slice(0,currentBands)].reverse());
let fb=true; if(f.valid&&!b.valid)fb=true;else if(!f.valid&&b.valid)fb=false;else if(f.valid&&b.valid){fb=!(b.seriesInfo!==""&&f.seriesInfo==="");}
let h='';
const row=(d,isF,best)=>{
if(!d.valid&&!best)return'';
const cls=best?'result-row best-match':'result-row';
const arrowSvg = isF ? `<svg viewBox="0 0 24 24"><path d="M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z"></path></svg>` : `<svg viewBox="0 0 24 24"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"></path></svg>`;
const ic = isF ? "direction-icon" : "direction-icon reverse";
const btn = isEmbed ? `<button class="btn-select" onclick="selectResult('${formatOhmsBOM(d.ohms)}')">Select</button>` : '';
const badge = best ? `<span class="badge-success" style="margin-left:0.5rem; padding:0.1rem 0.5rem; font-size:0.7rem; vertical-align:middle;">Likely</span>` : '';
const ser=d.seriesInfo?` <span class="match-badge">${d.seriesInfo}</span>`:'';
return `<div class="${cls}">
<div class="${ic}">${arrowSvg}</div>
<div class="val-group">
<span class="val-text">${formatOhmsUI(d.ohms)}${ser}</span>
<span class="meta-text">±${d.tol}%${d.tcr?` | ${d.tcr}ppm`:''}${badge}</span>
</div>
${btn}
</div>`;
};
if(!f.valid&&!b.valid) h='<div style="text-align:center;color:red;padding:1rem">Invalid</div>';
else{
if(fb){h+=row(f,true,true);h+=row(b,false,false);}
else{h+=row(b,false,true);h+=row(f,true,false);}
}
document.getElementById('results').innerHTML=h;
}
function selectResult(vStr){
let d=`Resistor: ${currentBands}-Band, ±${colors[bandState[currentBands===3?null:(currentBands>=5?4:3)]]?.tol||20}%`;
if(bridge) bridge.sendResult(vStr, d);
}
function parseResistorMeta(d){
let r={bands:4,tolIdx:-1,tcrIdx:-1}; if(d.includes("3-Band"))r.bands=3;else if(d.includes("5-Band"))r.bands=5;else if(d.includes("6-Band"))r.bands=6;
const tm=d.match(/±\s*([\d.]+)%/); if(tm){const t=parseFloat(tm[1]);const c=colors.findIndex(x=>x.tol===t);if(c!==-1)r.tolIdx=c;}
const tc=d.match(/(\d+)\s*ppm/i); if(tc){const p=parseInt(tc[1]);const c=colors.findIndex(x=>x.tcr===p);if(c!==-1)r.tcrIdx=c;}
return r;
}
init();
bridge = new ToolBridge({
onData: (d) => {
if(d.value) document.getElementById('nominal-input').value = d.value;
let meta = {bands:4,tolIdx:-1,tcrIdx:-1};
if(d.description) meta = parseResistorMeta(d.description);
setBands(meta.bands);
applyNominal();
if(meta.tolIdx!==-1){ if(meta.bands===4)bandState[3]=meta.tolIdx; else if(meta.bands>=5)bandState[4]=meta.tolIdx; }
if(meta.tcrIdx!==-1 && meta.bands===6) bandState[5]=meta.tcrIdx;
renderPickers(); calculate();
}
});
</script>
</body>
</html>

229
docs/spyglass.js Normal file
View file

@ -0,0 +1,229 @@
/**
* spyglass.js
* Handles zooming, panning, and coordinate adjustment for PCB previews.
* v2.1 - Added Pointer Events for Mobile Support (Pinch & Touch-Drag)
*/
class PcbSpyglass {
/**
* @param {string} canvasId
* @param {string} overlayId
* @param {function} onUpdateCallback - Optional. Called with (x, y) when user drags.
*/
constructor(canvasId, overlayId, onUpdateCallback = null) {
this.canvas = document.getElementById(canvasId);
this.overlay = document.getElementById(overlayId);
this.container = this.canvas ? this.canvas.parentElement : null;
this.onUpdate = onUpdateCallback;
if (!this.canvas) {
console.error("Spyglass: Canvas element not found");
return;
}
this.ctx = this.canvas.getContext('2d');
this.zoom = 2;
this.img = null;
this.x = 0;
this.y = 0;
this.enabled = false;
// Pointer State
this.isDragging = false;
this.lastMouseX = 0;
this.lastMouseY = 0;
this.evCache = []; // Cache for multi-touch
this.prevDiff = -1; // For pinch zoom
// Bind Events
this.canvas.addEventListener('wheel', this.handleWheel.bind(this), { passive: false });
// Unified Pointer Events (Mouse + Touch)
this.canvas.addEventListener('pointerdown', this.handlePointerDown.bind(this));
this.canvas.addEventListener('pointermove', this.handlePointerMove.bind(this));
this.canvas.addEventListener('pointerup', this.handlePointerUp.bind(this));
this.canvas.addEventListener('pointercancel', this.handlePointerUp.bind(this));
this.canvas.addEventListener('pointerout', this.handlePointerUp.bind(this));
this.canvas.addEventListener('pointerleave', this.handlePointerUp.bind(this));
}
/**
* Sets the image and coordinates.
* @param {ImageBitmap|HTMLImageElement} imgSource
* @param {number} x
* @param {number} y
* @param {boolean} autoCloseOld - If true, .close() is called on the old bitmap (Use false for galleries)
*/
setTarget(imgSource, x, y, autoCloseOld = true) {
if (!imgSource) return;
// MEMORY CLEANUP: Only if requested
if (autoCloseOld && this.img && this.img !== imgSource && typeof this.img.close === 'function') {
try { this.img.close(); } catch(e) {}
}
this.img = imgSource;
this.x = x;
this.y = y;
this.enabled = true;
if (this.container) {
this.container.classList.remove('empty');
this.container.classList.add('active');
}
this.render();
this.updateOverlay();
}
clear() {
this.enabled = false;
this.img = null;
if (this.container) {
this.container.classList.remove('active');
this.container.classList.add('empty');
}
this.ctx.fillStyle = '#ffffff';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
if (this.overlay) this.overlay.style.display = 'none';
}
handleWheel(e) {
e.preventDefault();
e.stopPropagation();
const d = e.deltaY > 0 ? -0.5 : 0.5;
this.zoom = Math.max(0.5, Math.min(10, this.zoom + d));
this.render();
this.updateOverlay();
}
// --- POINTER HANDLING (Desktop + Mobile) ---
handlePointerDown(e) {
if (!this.enabled) return;
this.canvas.setPointerCapture(e.pointerId);
this.evCache.push(e);
if (this.evCache.length === 1) {
this.isDragging = true;
this.lastMouseX = e.clientX;
this.lastMouseY = e.clientY;
this.canvas.style.cursor = 'grabbing';
}
}
handlePointerMove(e) {
if (!this.enabled) return;
// Update cache
const index = this.evCache.findIndex(cached => cached.pointerId === e.pointerId);
if (index > -1) this.evCache[index] = e;
// 1. PINCH ZOOM (2 Fingers)
if (this.evCache.length === 2) {
e.preventDefault();
const dx = this.evCache[0].clientX - this.evCache[1].clientX;
const dy = this.evCache[0].clientY - this.evCache[1].clientY;
const curDiff = Math.hypot(dx, dy);
if (this.prevDiff > 0) {
const diff = curDiff - this.prevDiff;
// PATCH 1: Increased Zoom Speed (0.01 -> 0.1)
this.zoom = Math.max(0.5, Math.min(10, this.zoom + (diff * 0.1)));
this.render();
this.updateOverlay();
}
this.prevDiff = curDiff;
return;
}
// 2. PANNING (1 Finger / Mouse)
if (this.isDragging && this.evCache.length === 1) {
e.preventDefault();
const dx = e.clientX - this.lastMouseX;
const dy = e.clientY - this.lastMouseY;
this.lastMouseX = e.clientX;
this.lastMouseY = e.clientY;
// PATCH 2: Speed Multiplier for Touch
// Mouse stays 1:1 for precision, Touch gets 5x speed to traverse faster
const speed = (e.pointerType === 'touch') ? 5 : 1.0;
this.x -= (dx * speed) / this.zoom;
this.y -= (dy * speed) / this.zoom;
this.render();
if (this.onUpdate) {
this.onUpdate(Math.round(this.x), Math.round(this.y));
}
}
}
handlePointerUp(e) {
const index = this.evCache.findIndex(cached => cached.pointerId === e.pointerId);
if (index > -1) this.evCache.splice(index, 1);
if (this.evCache.length < 2) this.prevDiff = -1;
if (this.evCache.length === 0) {
this.isDragging = false;
if(this.canvas) this.canvas.style.cursor = 'crosshair';
} else if (this.evCache.length === 1) {
// Reset reference point when switching from pinch to pan
this.lastMouseX = this.evCache[0].clientX;
this.lastMouseY = this.evCache[0].clientY;
}
}
updateOverlay() {
if (this.overlay) {
this.overlay.innerText = this.zoom.toFixed(1) + 'x';
this.overlay.style.display = this.enabled ? 'block' : 'none';
}
}
render() {
if (!this.enabled || !this.img) return;
const w = this.canvas.width;
const h = this.canvas.height;
const srcW = w / this.zoom;
const srcH = h / this.zoom;
this.ctx.fillStyle = '#ffffff';
this.ctx.fillRect(0, 0, w, h);
try {
this.ctx.drawImage(
this.img,
this.x - srcW / 2,
this.y - srcH / 2,
srcW, srcH,
0, 0, w, h
);
// Crosshair (Red)
const cx = Math.floor(w / 2) + 0.5;
const cy = Math.floor(h / 2) + 0.5;
this.ctx.strokeStyle = '#ff0000';
this.ctx.lineWidth = 2;
this.ctx.lineCap = 'butt';
this.ctx.beginPath();
this.ctx.moveTo(0, cy); this.ctx.lineTo(w, cy);
this.ctx.moveTo(cx, 0); this.ctx.lineTo(cx, h);
this.ctx.stroke();
} catch (e) {
if (e.name !== 'InvalidStateError' && e.name !== 'DOMException') {
console.error("Spyglass Render Error:", e);
}
}
}
}

278
docs/stitch-editor.js Normal file
View file

@ -0,0 +1,278 @@
/* stitch-editor.js */
// PanZoomCanvas class removed (moved to canvas-ui.js)
class StitchEditor {
constructor(dbInstance, cvInstance) {
// ... (Constructor remains the same) ...
// It will now use the global PanZoomCanvas class
this.db = dbInstance;
this.cv = cvInstance;
this.modal = document.getElementById('stitch-modal');
this.srcId = null; this.dstId = null;
this.points = [];
this.colors = ['#ff0000', '#00ff00', '#0000ff', '#ffff00', '#00ffff', '#ff00ff', '#ffffff', '#ff8800', '#88ff00'];
this.viewSrc = new PanZoomCanvas('stitch-canvas-src',
(c, k) => this.drawPts(c, k, 's'),
(x, y) => this.addPt(x, y, 's'),
(x, y, m, i) => this.hit(x, y, m, i, 's')
);
this.viewDst = new PanZoomCanvas('stitch-canvas-dst',
(c, k) => this.drawPts(c, k, 'd'),
(x, y) => this.addPt(x, y, 'd'),
(x, y, m, i) => this.hit(x, y, m, i, 'd')
);
this.injectFlipControls();
}
injectFlipControls() {
const toolbar = document.querySelector('.stitch-toolbar');
if(toolbar && !document.getElementById('btn-stitch-flip')) {
const container = document.createElement('div');
container.style.display = 'flex';
container.style.gap = '5px';
container.style.marginRight = 'auto';
const btnFlip = document.createElement('button');
btnFlip.id = 'btn-stitch-flip';
btnFlip.className = 'secondary';
btnFlip.innerText = '↔ Flip Target';
btnFlip.onclick = () => this.toggleFlip(btnFlip);
container.appendChild(btnFlip);
if(toolbar.firstChild) toolbar.insertBefore(container, toolbar.firstChild);
else toolbar.appendChild(container);
}
}
toggleFlip(btn) {
const newVal = !this.viewDst.isMirrored;
this.viewDst.setMirror(newVal);
btn.style.background = newVal ? '#e0f2fe' : '';
btn.style.borderColor = newVal ? '#2563eb' : '';
this.refresh();
}
getGridCoords(w, h, n) {
const coords = [];
const factors = (n === 2) ? [0.25, 0.75] : [0.15, 0.5, 0.85];
for(let fy of factors) {
for(let fx of factors) {
coords.push({ x: w * fx, y: h * fy });
}
}
return coords;
}
getOverlapRect(wSrc, hSrc, wDst, hDst, H) {
const corners = [
{x:0, y:0}, {x:wSrc, y:0}, {x:wSrc, y:hSrc}, {x:0, y:hSrc}
].map(p => this.cv.projectPoint(p.x, p.y, H)).filter(p => p !== null);
if(corners.length < 4) return null;
const minPx = Math.min(...corners.map(p => p.x));
const maxPx = Math.max(...corners.map(p => p.x));
const minPy = Math.min(...corners.map(p => p.y));
const maxPy = Math.max(...corners.map(p => p.y));
const iMinX = Math.max(0, minPx);
const iMaxX = Math.min(wDst, maxPx);
const iMinY = Math.max(0, minPy);
const iMaxY = Math.min(hDst, maxPy);
if (iMinX >= iMaxX || iMinY >= iMaxY) return null;
return { x: iMinX, y: iMinY, w: iMaxX - iMinX, h: iMaxY - iMinY };
}
setGrid(n, explicitH = null, explicitInvH = null) {
if(!this.viewSrc.bmp || !this.viewDst.bmp) return;
let H = explicitH;
let invH = explicitInvH;
if (!H && this.points.length >= 4) {
const res = this.cv.solveManual(this.points);
if(res) { H = res.hData; invH = res.invHData; }
}
this.points = [];
if (H && invH) {
const rect = this.getOverlapRect(
this.viewSrc.bmp.width, this.viewSrc.bmp.height,
this.viewDst.bmp.width, this.viewDst.bmp.height,
H
);
if (rect) {
const dstGrid = this.getGridCoords(rect.w, rect.h, n).map(p => ({
x: rect.x + p.x,
y: rect.y + p.y
}));
dstGrid.forEach((dPt, i) => {
const sPt = this.cv.projectPoint(dPt.x, dPt.y, invH);
if (sPt && sPt.x >= 0 && sPt.y >= 0 && sPt.x <= this.viewSrc.bmp.width && sPt.y <= this.viewSrc.bmp.height) {
this.points.push({ s: sPt, d: dPt, color: this.colors[i % this.colors.length] });
}
});
if (this.points.length > 0) {
this.refresh();
return;
}
}
}
const sGrid = this.getGridCoords(this.viewSrc.bmp.width, this.viewSrc.bmp.height, n);
const dGrid = this.getGridCoords(this.viewDst.bmp.width, this.viewDst.bmp.height, n);
for(let i=0; i<sGrid.length; i++) {
this.points.push({ s: sGrid[i], d: dGrid[i], color: this.colors[i % this.colors.length] });
}
this.refresh();
}
clearPoints() {
this.points = [];
this.refresh();
}
isReflection(H) {
if (!H || H.length < 5) return false;
const det = (H[0] * H[4]) - (H[1] * H[3]);
return det < 0;
}
async open(srcImgId, dstImgId) {
this.srcId = srcImgId;
this.dstId = dstImgId;
const i1 = await this.db.getImage(srcImgId);
const i2 = await this.db.getImage(dstImgId);
if(!i1 || !i2) return alert("Images not found");
document.getElementById('stitch-title').innerText = `Stitch: ${i1.name} ↔ ${i2.name}`;
const bS = await createImageBitmap(i1.blob);
const bD = await createImageBitmap(i2.blob);
this.viewSrc.setImage(bS);
this.viewDst.setImage(bD);
this.points = [];
const existing = await this.db.getOverlapsForPair(srcImgId, dstImgId);
let shouldFlip = false;
if (existing) {
if (existing.isManual && existing.manualPoints) {
existing.manualPoints.forEach((p, i) => {
let ptS, ptD;
if(existing.fromImageId === this.srcId) { ptS=p.s; ptD=p.d; }
else { ptS=p.d; ptD=p.s; }
this.points.push({ s: {x:ptS.x, y:ptS.y}, d: {x:ptD.x, y:ptD.y}, color: this.colors[i % this.colors.length] });
});
if (this.points.length >= 3) {
const tempRes = this.cv.solveManual(this.points);
if (tempRes && this.isReflection(tempRes.hData)) {
shouldFlip = true;
}
}
} else if (!existing.isManual && existing.homography) {
let hToUse = existing.homography;
let invHToUse = existing.inverseHomography;
if (existing.fromImageId !== this.srcId) {
hToUse = existing.inverseHomography;
invHToUse = existing.homography;
}
if (this.isReflection(hToUse)) {
shouldFlip = true;
}
this.setGrid(3, hToUse, invHToUse);
}
} else {
this.setGrid(3);
}
this.viewDst.setMirror(shouldFlip);
const flipBtn = document.getElementById('btn-stitch-flip');
if(flipBtn) {
flipBtn.style.background = shouldFlip ? '#e0f2fe' : '';
flipBtn.style.borderColor = shouldFlip ? '#2563eb' : '';
}
this.refresh();
this.modal.style.display = 'flex';
}
refresh() { this.viewSrc.draw(); this.viewDst.draw(); }
drawPts(ctx, k, side) {
const ik = 1/k;
const isMirrored = (side === 'd' && this.viewDst.isMirrored);
const width = (side === 'd' && this.viewDst.bmp) ? this.viewDst.bmp.width : 0;
this.points.forEach((p, idx) => {
const pt = (side==='s') ? p.s : p.d;
const label = (idx + 1).toString();
let drawX = pt.x;
if (isMirrored) drawX = width - pt.x;
const r=10*ik, len=20*ik, gap=2*ik;
const path = (c) => {
c.beginPath(); c.arc(drawX, pt.y, r, 0, Math.PI*2);
c.moveTo(drawX-len, pt.y); c.lineTo(drawX-gap, pt.y);
c.moveTo(drawX+gap, pt.y); c.lineTo(drawX+len, pt.y);
c.moveTo(drawX, pt.y-len); c.lineTo(drawX, pt.y-gap);
c.moveTo(drawX, pt.y+gap); c.lineTo(drawX, pt.y+len);
};
ctx.strokeStyle='black'; ctx.lineWidth=3*ik; path(ctx); ctx.stroke();
ctx.strokeStyle=p.color; ctx.lineWidth=1.5*ik; path(ctx); ctx.stroke();
ctx.font = `bold ${14*ik}px sans-serif`;
ctx.lineWidth = 3*ik; ctx.strokeStyle='black'; ctx.strokeText(label, drawX+8*ik, pt.y-8*ik);
ctx.fillStyle = 'white'; ctx.fillText(label, drawX+8*ik, pt.y-8*ik);
});
}
addPt(x, y, side) {
const p = { s:{x:0,y:0}, d:{x:0,y:0}, color: this.colors[this.points.length % this.colors.length] };
if(side==='s') { p.s={x,y}; p.d={x:this.viewDst.bmp.width/2, y:this.viewDst.bmp.height/2}; }
else { p.d={x,y}; p.s={x:this.viewSrc.bmp.width/2, y:this.viewSrc.bmp.height/2}; }
this.points.push(p);
this.refresh();
}
hit(x, y, mode, idx, side) {
if(mode==='check') {
for(let i=this.points.length-1; i>=0; i--) {
const pt = (side==='s')?this.points[i].s:this.points[i].d;
if(Math.hypot(x-pt.x, y-pt.y) < 20) return i;
}
return -1;
} else if(mode==='move') {
const pt = (side==='s')?this.points[idx].s:this.points[idx].d;
pt.x+=x; pt.y+=y; this.refresh();
} else if(mode==='delete') {
const i=this.hit(x,y,'check',-1,side);
if(i!==-1) { this.points.splice(i,1); this.refresh(); }
}
}
async save() {
if(this.points.length < 4) return alert("Need at least 4 points to solve.");
const res = this.cv.solveManual(this.points);
if(!res) return alert("Solve failed. Points might be collinear or overlapping.");
await this.db.deleteOverlapsForPair(this.srcId, this.dstId);
await this.db.addOverlap({
id: uuid(),
fromImageId: this.srcId,
toImageId: this.dstId,
homography: res.hData,
inverseHomography: res.invHData,
matchCount: this.points.length,
isManual: true,
manualPoints: this.points.map(p => ({s:p.s, d:p.d}))
});
if(typeof renderList === 'function') renderList();
if(typeof renderConnectionsList === 'function') renderConnectionsList();
window.history.back();
}
}

1530
docs/studio.html Normal file

File diff suppressed because it is too large Load diff

2349
docs/studio.js Normal file

File diff suppressed because it is too large Load diff

163
docs/tool-bridge.js Normal file
View file

@ -0,0 +1,163 @@
/**
* tool-bridge.js
* Common logic for connecting Component Tools (iframe) to the main Application (studio.js).
* Handles: PostMessage, Spyglass Views, Promotion, and Dragging.
*/
class ToolBridge {
constructor(config) {
this.cfg = config;
this.availableViews = [];
this.currentViewIdx = 0;
this.spyglass = null;
this.pendingViewInit = null; // Buffer for race condition fix
// Initialize Spyglass
const initSpyglass = () => {
const canvas = document.getElementById('tool-canvas');
if (typeof PcbSpyglass !== 'undefined' && canvas) {
this.spyglass = new PcbSpyglass('tool-canvas', 'tool-zoom', (x, y) => this.handleDrag(x, y));
// If we received data before spyglass was ready, apply it now
if (this.pendingViewInit !== null) {
this.selectView(this.pendingViewInit);
this.pendingViewInit = null;
}
}
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initSpyglass);
} else {
// If already loaded, init immediately (microtask)
setTimeout(initSpyglass, 0);
}
window.addEventListener('message', this.handleMessage.bind(this));
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
window.parent.postMessage({ type: 'ESC_PRESSED' }, '*');
}
});
}
handleMessage(event) {
if (!event.data || event.data.type !== 'INIT_TOOL') return;
const d = event.data;
// 1. Pass Data to Tool
if (this.cfg.onData) {
this.cfg.onData({ value: d.value, description: d.description });
}
// 2. Handle Views
if (d.views && d.views.length > 0) {
this.availableViews = d.views;
this.renderViewSelector();
const mainIdx = this.availableViews.findIndex(v => v.isMain);
const targetIdx = mainIdx !== -1 ? mainIdx : 0;
this.toggleSpyglass(true);
// Check if spyglass is ready
if (this.spyglass) {
this.selectView(targetIdx);
} else {
// Buffer it for later
this.pendingViewInit = targetIdx;
}
} else if (d.bitmap) {
// Legacy support
this.availableViews = [{ bitmap: d.bitmap, x: d.x, y: d.y, name: 'Main', isMain: true }];
this.renderViewSelector();
this.toggleSpyglass(true);
if (this.spyglass) this.selectView(0);
else this.pendingViewInit = 0;
} else {
this.toggleSpyglass(false);
}
}
toggleSpyglass(show) {
const el = document.getElementById('spyglass-col');
if (el) {
if (show) el.classList.add('active');
else {
el.classList.remove('active');
if (this.spyglass) this.spyglass.clear();
}
}
}
renderViewSelector() {
const container = document.getElementById('view-selector');
if (!container) return;
container.innerHTML = '';
if (this.availableViews.length <= 1) return;
this.availableViews.forEach((v, idx) => {
const thumb = document.createElement('div');
thumb.className = 'view-thumb';
if (v.isMain) thumb.style.borderColor = 'var(--accent, #2563eb)';
const cvs = document.createElement('canvas');
cvs.width = 100; cvs.height = 100;
const ctx = cvs.getContext('2d');
try {
const sSize = 200;
const sx = Math.max(0, v.x - sSize/2);
const sy = Math.max(0, v.y - sSize/2);
ctx.drawImage(v.bitmap, sx, sy, sSize, sSize, 0, 0, 100, 100);
} catch(e) {}
thumb.innerHTML = `<span>${v.name}</span>`;
thumb.appendChild(cvs);
if (!v.isMain) {
const btn = document.createElement('button');
btn.className = 'promote-btn';
btn.innerText = '★';
btn.onclick = (e) => { e.stopPropagation(); this.promoteView(idx); };
thumb.appendChild(btn);
}
thumb.onclick = () => {
const all = container.querySelectorAll('.view-thumb');
all.forEach(t => t.style.borderColor = '#ccc');
thumb.style.borderColor = 'var(--accent, #2563eb)';
this.selectView(idx);
};
container.appendChild(thumb);
});
}
selectView(idx) {
this.currentViewIdx = idx;
const v = this.availableViews[idx];
if (this.spyglass && v && v.bitmap) {
this.spyglass.setTarget(v.bitmap, v.x, v.y, false);
}
}
promoteView(idx) {
const v = this.availableViews[idx];
window.parent.postMessage({ type: 'PROMOTE_VIEW', x: v.x, y: v.y, imgId: v.imgId }, '*');
}
handleDrag(x, y) {
if (this.availableViews[this.currentViewIdx]) {
this.availableViews[this.currentViewIdx].x = x;
this.availableViews[this.currentViewIdx].y = y;
}
if (this.availableViews[this.currentViewIdx].isMain) {
window.parent.postMessage({ type: 'UPDATE_POS', x: x, y: y }, '*');
}
}
sendResult(valueOverride = null, descOverride = null) {
const val = valueOverride || (this.cfg.onNominal ? this.cfg.onNominal() : null);
const desc = descOverride || (this.cfg.onDescription ? this.cfg.onDescription() : "");
if (val) window.parent.postMessage({ type: 'COMPONENT_UPDATE', value: val, description: desc }, '*');
}
}

336
docs/tool-style.css Normal file
View file

@ -0,0 +1,336 @@
/* tool-style.css - Shared styles for Component Editors */
@import url('common.css');
:root {
--accent: #2563eb;
--text: #333;
--bg: #f4f4f9;
--card: #ffffff;
--border: #ccc;
--focus-ring: rgba(37, 99, 235, 0.2);
--resistor-body: #82cae0;
--resistor-shadow: #5ea8bd;
}
/* --- CORE LAYOUT --- */
html { font-size: 16px; box-sizing: border-box; height: 100%; overflow: hidden; }
*, *:before, *:after { box-sizing: inherit; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
margin: 0; padding: 0;
height: 100%; width: 100%;
display: flex; justify-content: center; align-items: center;
}
.container {
background: var(--card); padding: 1.5rem; border-radius: 1rem;
box-shadow: 0 0.5rem 2rem rgba(0,0,0,0.05);
width: 95%; max-width: 1000px; height: 95%; max-height: 95%;
margin: 1rem; display: flex; flex-direction: column;
overflow-y: auto; scrollbar-width: thin; gap: 0.5rem;
position: relative;
}
h1 { margin: 0; font-size: 1.5rem; text-align: center; flex-shrink: 0; }
/* --- TOP SECTION --- */
.top-section {
display: flex; gap: 1rem; width: 100%;
align-items: stretch; justify-content: center;
flex-shrink: 0;
}
/* Spyglass */
.col-spyglass {
flex: 0 0 auto;
display: none;
flex-direction: row;
gap: 0.5rem;
}
.col-spyglass.active { display: flex; }
.spyglass-wrapper {
width: 200px; height: 200px;
border-radius: 0.5rem; border: 1px solid var(--border);
padding: 0.5rem; position: relative; background: rgba(0,0,0,0.02);
flex-shrink: 0;
}
.spyglass-canvas {
width: 100%; height: 100%; object-fit: contain;
cursor: crosshair; display: block; background: #fff; border-radius: 0.25rem;
}
.spyglass-hint { display: none; }
.zoom-badge {
position: absolute; bottom: 0.5rem; right: 0.5rem;
background: rgba(0,0,0,0.6); color: white;
font-size: 0.7rem; padding: 2px 6px;
border-radius: 4px; pointer-events: none;
}
/* Thumbnails */
#view-selector {
display: flex; flex-direction: column; gap: 4px;
width: 72px; height: 200px;
overflow-y: auto; overflow-x: hidden;
padding-bottom: 0; margin-top: 0;
border-left: 1px solid var(--border); padding-left: 4px;
}
.view-thumb { width: 60px; height: 60px; border: 2px solid #ccc; border-radius: 4px; cursor: pointer; overflow: hidden; position: relative; flex-shrink: 0; }
.view-thumb.active { border-color: var(--accent); box-shadow: 0 0 0 2px var(--focus-ring); }
.view-thumb canvas { width: 100%; height: 100%; object-fit: cover; pointer-events: none; }
.view-thumb span { position: absolute; bottom: 0; left: 0; right: 0; background: rgba(0,0,0,0.6); color: white; font-size: 8px; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.view-thumb .promote-btn {
display: flex; align-items: center; justify-content: center;
position: absolute; top: 0; right: 0;
background: #f59e0b; color: white; border: none;
width: 18px; height: 18px; font-size: 12px; line-height: 1;
cursor: pointer; z-index: 10;
}
/* Controls */
.col-controls {
flex: 1; display: flex; flex-direction: column;
justify-content: center; gap: 1rem;
max-width: 600px; margin: 0 auto; width: 100%;
}
.top-section:has(.col-spyglass.active) .col-controls { margin: 0; }
/* INPUTS */
.reverse-input-group { display: flex; flex-direction: column; gap: 0.5rem; width: 100%; }
.input-row { display: flex; gap: 0.5rem; width: 100%; }
.inp-nominal { padding: 0.6rem 0.8rem; border: 1px solid var(--border); border-radius: 0.5rem; font-size: 1rem; flex: 1; min-width: 0; }
.btn-action { padding: 0.6rem 1rem; background: var(--accent); color: white; border: none; border-radius: 0.5rem; font-weight: 600; cursor: pointer; white-space: nowrap; flex-shrink: 0; }
.input-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; width: 100%; }
.form-group { display: flex; flex-direction: column; gap: 0.3rem; min-width: 0; }
.input-wrapper { position: relative; width: 100%; }
.input-wrapper input { padding-right: 2.5rem !important; }
.unit { position: absolute; right: 0.8rem; top: 50%; transform: translateY(-50%); color: #9ca3af; font-size: 0.9rem; pointer-events: none; }
label { font-size: 0.8rem; font-weight: 700; text-transform: uppercase; opacity: 0.8; }
input {
width: 100%; padding: 0.8rem;
border: 1px solid var(--border);
border-radius: 0.5rem;
font-size: 1.1rem; font-weight: 600;
transition: all 0.2s;
background: #fff; color: inherit; box-sizing: border-box;
}
input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--focus-ring); }
.controls-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(80px, 1fr)); gap: 0.5rem; width: 100%; }
.btn-mode { padding: 0.6rem; border: 1px solid #e0e0e0; background: #fff; cursor: pointer; border-radius: 0.5rem; font-weight: 600; font-size: 0.9rem; color: #555; text-align: center; white-space: nowrap; }
.btn-mode.active { background: var(--accent); color: white; border-color: var(--accent); }
/* --- COMPONENT GRAPHIC --- */
.resistor-wrapper-outer {
position: relative; width: 100%; height: 9rem; flex-shrink: 0;
display: flex; justify-content: center; align-items: center; z-index: 0; margin: 0;
}
.resistor-wrapper-outer::before {
content: ''; position: absolute; top: 50%; left: 0; width: 100%; height: 0.6rem;
transform: translateY(-50%); background: #9da5b4;
background-image: linear-gradient(to bottom, #d0d6e0, #8a92a0, #d0d6e0); z-index: -1;
}
.resistor-dumbbell-shape {
display: inline-flex; align-items: center; justify-content: center; position: relative; z-index: 10;
transform: scale(0.9); transform-origin: center;
}
.resistor-dumbbell-shape::before, .resistor-dumbbell-shape::after {
content: ''; display: block; width: 3.5rem; height: 8rem;
background-color: var(--resistor-body); border-radius: 1.5rem; flex-shrink: 0;
background-image: linear-gradient(to right, rgba(255,255,255,0.3) 0%, rgba(255,255,255,0) 20%, rgba(0,0,0,0) 80%, rgba(0,0,0,0.1) 100%), linear-gradient(to bottom, rgba(255,255,255,0.5) 0%, rgba(0,0,0,0) 40%, rgba(0,0,0,0.3) 100%);
box-shadow: inset -2px 0 5px rgba(0,0,0,0.1), 0 0.8rem 1.2rem rgba(0,0,0,0.15); border: 1px solid var(--resistor-shadow);
}
.resistor-dumbbell-shape::before { border-top-right-radius: 0.6rem; border-bottom-right-radius: 0.6rem; margin-right: -0.4rem; }
.resistor-dumbbell-shape::after { border-top-left-radius: 0.6rem; border-bottom-left-radius: 0.6rem; margin-left: -0.4rem; }
.bands-container {
display: inline-flex; align-items: stretch; justify-content: center; height: 6rem;
background-color: var(--resistor-body);
box-shadow: inset 0 0.8rem 0.8rem -0.2rem rgba(0,0,0,0.15), inset 0 -0.8rem 0.8rem -0.2rem rgba(0,0,0,0.2);
padding: 0 0.5rem; gap: 0.5rem; z-index: 5;
}
/* --- COLOR CLASSES (Shared) --- */
.c-bk { background-color: #000; color: #fff !important; }
.c-bn { background-color: #5d4037; color: #fff !important; }
.c-rd { background-color: #d32f2f; color: #fff !important; }
.c-og { background-color: #f57c00; color: #000 !important; }
.c-ye { background-color: #ffeb3b; color: #000 !important; }
.c-gn { background-color: #2e7d32; color: #fff !important; }
.c-bu { background-color: #1565c0; color: #fff !important; }
.c-vt { background-color: #7b1fa2; color: #fff !important; }
.c-gy { background-color: #9e9e9e; color: #000 !important; }
.c-wh { background-color: #fff; border: 1px solid #ddd; color: #000 !important; }
/* Metallic (Horizontal Gradient for Dropdowns) */
.c-gd { background: linear-gradient(to right, #fbf0b5 0%, #c49a00 20%, #fbf0b5 40%, #b68f00 60%, #fbf0b5 80%); color: #000 !important; }
.c-sv { background: linear-gradient(to right, #fff 0%, #9e9e9e 20%, #fff 40%, #808080 60%, #e8e8e8 80%); color: #000 !important; }
/* Metallic Overrides (Vertical/Top Gradient for Component Bands) */
.custom-select__trigger.c-gd { background-image: linear-gradient(to top, #fbf0b5 0%, #c49a00 20%, #fbf0b5 40%, #b68f00 60%, #fbf0b5 80%) !important; }
.custom-select__trigger.c-sv { background-image: linear-gradient(to top, #ffffff 0%, #9e9e9e 20%, #ffffff 40%, #808080 60%, #e8e8e8 80%) !important; }
/* Color Text Contrast Helper */
.c-ye span, .c-wh span, .c-gd span, .c-sv span, .c-og span, .c-gy span { color: #222 !important; text-shadow: 0 1px 1px rgba(255,255,255,0.5); }
/* --- DROPDOWN --- */
.custom-select-wrapper { position: relative; width: 2.8rem; height: 100%; }
.custom-select__trigger {
position: relative; display: flex; align-items: center; justify-content: center; height: 100%;
cursor: pointer; transition: transform 0.1s;
background-image: linear-gradient(to bottom, rgba(255,255,255,0.4) 0%, rgba(255,255,255,0) 40%, rgba(0,0,0,0.2) 100%);
box-shadow: inset 1px 0 2px rgba(0,0,0,0.2), inset -1px 0 2px rgba(0,0,0,0.2);
}
.custom-select__trigger:active { transform: scale(0.95); }
.custom-select__trigger span {
writing-mode: vertical-lr; text-orientation: mixed; transform: rotate(180deg); color: #fff;
font-weight: 700; font-size: 0.9rem; text-shadow: 0 1px 2px rgba(0,0,0,0.5); white-space: nowrap; pointer-events: none;
}
#global-dropdown {
position: fixed; top: 0; left: 0; width: 12rem; background: #fff;
border: 1px solid #ccc; border-radius: 0.5rem;
box-shadow: 0 10px 40px rgba(0,0,0,0.4);
z-index: 99999; display: none; flex-direction: column;
max-height: 90vh; overflow-y: auto;
}
.custom-option { padding: 0.8rem 1rem; font-size: 1rem; font-weight: 600; cursor: pointer; text-align: center; border-bottom: 1px solid rgba(0,0,0,0.05); }
.custom-option:hover { filter: brightness(0.95); }
#global-dropdown.condensed .custom-option { padding: 0.5rem 0.5rem; font-size: 0.9rem; }
#global-dropdown.super-condensed .custom-option { padding: 0.25rem 0.2rem; font-size: 0.85rem; }
/* --- RESULTS --- */
.result-box { width: 100%; display: flex; flex-direction: column; gap: 0.5rem; flex-shrink: 0; padding-bottom: 0.5rem; }
.result-row { display: flex; align-items: center; padding: 0.8rem; background: #fff; border: 1px solid #eee; border-radius: 0.8rem; box-shadow: 0 2px 5px rgba(0,0,0,0.03); gap: 1rem; }
.best-match { background: #f8fbff; border: 1px solid #cce5ff; border-left: 0.4rem solid var(--accent); }
.val-group { display: flex; flex-direction: column; gap: 0.2rem; flex: 1; }
.val-text { font-size: 1.4rem; font-weight: 800; color: #222; }
.meta-text { color: #666; font-size: 0.9rem; font-weight: 500;}
.match-badge { background: #e0f2fe; color: var(--accent); padding: 0.4rem 1rem; border-radius: 2rem; font-size: 0.8rem; font-weight: 700; white-space: nowrap;}
.badge-success { background: #dcfce7; color: #166534; padding: 0.4rem 1rem; border-radius: 2rem; font-size: 0.8rem; font-weight: 700; white-space: nowrap;}
.direction-icon { width: 2.2rem; height: 2.2rem; border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; background: #eee; color: #666; }
.direction-icon svg { width: 1rem; height: 1rem; fill: currentColor; }
.best-match .direction-icon { background: var(--accent); color: #fff; }
.direction-icon.reverse { background: #f59e0b; color: #fff; }
.btn-select { background: #059669; color: white; border: none; padding: 0.5rem 1rem; border-radius: 0.4rem; cursor: pointer; font-weight: 700; white-space: nowrap; margin-left: auto; }
.btn-select:hover { background: #047857; }
@media (max-width: 600px) {
/* 1. Global Setup */
html, body {
height: 100%;
overflow: hidden;
margin: 0; padding: 0;
}
/* 2. Container: Fill the parent iframe completely */
.container {
margin: 0;
width: 100%;
height: 100%;
border-radius: 0; /* Parent handles radius */
box-shadow: none; /* Parent handles shadow */
border: none;
padding: 0.25rem;
gap: 0.25rem;
overflow-y: auto;
}
/* 3. Header: Hidden inside tool */
h1 { display: none; }
/* 4. Top Section */
.top-section {
flex-direction: column;
align-items: center;
gap: 0.25rem;
flex-shrink: 0;
}
.col-controls {
width: 100%;
gap: 0.25rem;
}
/* 5. Inputs & Buttons */
input {
padding: 0 0.4rem;
font-size: 0.9rem;
height: 2rem;
}
.unit { font-size: 0.7rem; right: 0.25rem; }
.btn-action, .btn-select {
padding: 0 0.5rem;
font-size: 0.8rem;
height: 2rem;
line-height: 1;
white-space: nowrap;
}
/* BAND BUTTONS: Single Line Forced */
.controls-grid {
display: flex !important;
flex-direction: row;
width: 100%;
gap: 0.2rem;
}
.btn-mode {
flex: 1;
padding: 0 !important;
font-size: 0.75rem;
height: 2rem;
line-height: 2rem;
white-space: nowrap;
overflow: hidden;
}
.input-grid { gap: 0.25rem; }
.form-group { gap: 0; margin-bottom: 0; }
label { font-size: 0.7rem; margin: 0; line-height: 1.2; }
/* 6. Graphic */
.resistor-wrapper-outer {
height: 5rem;
min-height: 5rem;
margin: 0.125rem 0;
overflow: visible;
display: flex;
align-items: center;
justify-content: center;
}
.resistor-dumbbell-shape {
transform: scale(0.65);
transform-origin: center center;
width: 100%;
display: inline-flex;
}
.bands-container { gap: 0.125rem; }
/* 7. Results */
.result-row {
padding: 0.25rem 0.5rem;
gap: 0.5rem;
min-height: 2.5rem;
}
.val-text { font-size: 1.1rem; }
.meta-text { font-size: 0.75rem; }
/* 8. Dropdown Fix */
.col-controls .custom-select-wrapper {
height: 2rem;
}
.bands-container .custom-select-wrapper {
height: 100%;
}
.custom-select__trigger span { font-size: 0.75rem; }
}