Part-DB-server/assets/controllers/pages/valueCalculator_controller.js
Sebastian Almberg d73bc42362 Add a component value calculator & image generator
Adds a "Value calculator" tool (Tools menu) that decodes/encodes the value of
common components and generates a clean SVG picture you can attach to a part —
handy for assortments imported with blank thumbnails and sparse data.

Calculator tabs (bidirectional decode <-> encode), each drawing the part to the
selected package with a collapsible appearance panel and an "attach to part"
action (optionally as the master picture):
- Resistor: 4/5/6-band colour code
- Capacitor: value <-> 3-digit code <-> tolerance
- SMD resistor: value <-> 3-digit / 4-digit / EIA-96 code
- Inductor: colour-band code
- SMD inductor: value <-> uH code

Bulk "Generate component images" parts-table action:
- Classifies each selected part (resistor / capacitor / inductor / diode / LED,
  THT or SMD) with ComponentValueGuesser and detects value, voltage, tolerance,
  power, ppm, pitch, diameter, colour, SMD package and marking from the name,
  description and parameters.
- Renders a preview per part, lets you tweak appearance, and attaches the images
  in one go; can also write matching KiCad symbol/footprint/reference-prefix EDA
  fields.

Backend: ComponentValueGuesser (classification + detection + EDA suggestion),
GeneratedImageAttachmentHelper (stores the SVG through the existing attachment
pipeline, so it is sanitised on save), two POST endpoints on PartController
(generate_image, set_eda) guarded by `edit` + CSRF, a new `@tools.value_calculator`
permission, a Tools-tree entry and docs. All drawing is client-side in a Stimulus
controller; the un-sanitised live preview escapes part-derived text and validates
colours as defence-in-depth.

Tests: unit tests for ComponentValueGuesser and functional tests for the
endpoints, including permission and CSRF enforcement and the persisted side
effects.
2026-07-30 17:49:18 +02:00

2328 lines
105 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Controller} from "@hotwired/stimulus";
import {AlertSwal} from "../../helpers/swal";
import {trans} from "../../translator.js";
/**
* Color definitions for the resistor color code.
* digit: significant figure (null if the color can't be used for a digit band)
* multiplier: factor applied by a multiplier band
* tolerance: tolerance in percent (null if not usable as tolerance band)
* temp: temperature coefficient in ppm/K (null if not usable as temp. band)
* hex/text: colors used to draw the band and a readable label on top of it
*/
const RESISTOR_COLORS = {
black: {digit: 0, multiplier: 1e0, tolerance: null, temp: 250, hex: "#000000", text: "#ffffff"},
brown: {digit: 1, multiplier: 1e1, tolerance: 1, temp: 100, hex: "#5c3a21", text: "#ffffff"},
red: {digit: 2, multiplier: 1e2, tolerance: 2, temp: 50, hex: "#c8102e", text: "#ffffff"},
orange: {digit: 3, multiplier: 1e3, tolerance: null, temp: 15, hex: "#f25c05", text: "#000000"},
yellow: {digit: 4, multiplier: 1e4, tolerance: null, temp: 25, hex: "#f2c200", text: "#000000"},
green: {digit: 5, multiplier: 1e5, tolerance: 0.5, temp: 20, hex: "#1a8f3c", text: "#ffffff"},
blue: {digit: 6, multiplier: 1e6, tolerance: 0.25, temp: 10, hex: "#0a4ea3", text: "#ffffff"},
violet: {digit: 7, multiplier: 1e7, tolerance: 0.1, temp: 5, hex: "#6a2c91", text: "#ffffff"},
grey: {digit: 8, multiplier: 1e8, tolerance: 0.05, temp: 1, hex: "#808080", text: "#ffffff"},
white: {digit: 9, multiplier: 1e9, tolerance: null, temp: null, hex: "#f5f5f5", text: "#000000"},
gold: {digit: null, multiplier: 0.1, tolerance: 5, temp: null, hex: "#c2a000", text: "#000000"},
silver: {digit: null, multiplier: 0.01, tolerance: 10, temp: null, hex: "#b3b3b3", text: "#000000"},
};
// Capacitor tolerance letter codes (percent, or absolute in pF for small caps)
const CAP_TOLERANCE = {
B: "±0.10 pF", C: "±0.25 pF", D: "±0.5 pF", F: "±1%", G: "±2%",
J: "±5%", K: "±10%", M: "±20%", Z: "+80% / -20%",
};
// EIA-96 significant value lookup (code 01..96)
const EIA96_VALUES = [
100, 102, 105, 107, 110, 113, 115, 118, 121, 124, 127, 130, 133, 137, 140, 143,
147, 150, 154, 158, 162, 165, 169, 174, 178, 182, 187, 191, 196, 200, 205, 210,
215, 221, 226, 232, 237, 243, 249, 255, 261, 267, 274, 280, 287, 294, 301, 309,
316, 324, 332, 340, 348, 357, 365, 374, 383, 392, 402, 412, 422, 432, 442, 453,
464, 475, 487, 499, 511, 523, 536, 549, 562, 576, 590, 604, 619, 634, 649, 665,
681, 698, 715, 732, 750, 768, 787, 806, 825, 845, 866, 887, 909, 931, 953, 976,
];
const EIA96_MULTIPLIERS = {
Z: 0.001, Y: 0.01, R: 0.01, X: 0.1, S: 0.1, A: 1, B: 10, C: 100, D: 1000, E: 10000, F: 100000,
};
// Typical dimensions of axial THT resistors per power rating.
// len/dia = body length and diameter (mm), pitch = typical lead spacing (mm).
const RESISTOR_POWERS = {
"0.125": {label: "1/8 W", len: 3.4, dia: 1.9, pitch: 7.62, pitchIn: "0.3\""},
"0.25": {label: "1/4 W", len: 6.3, dia: 2.4, pitch: 10.16, pitchIn: "0.4\""},
"0.5": {label: "1/2 W", len: 9.0, dia: 3.2, pitch: 12.7, pitchIn: "0.5\""},
"1": {label: "1 W", len: 11.5, dia: 4.5, pitch: 15.24, pitchIn: "0.6\""},
"2": {label: "2 W", len: 15.5, dia: 5.0, pitch: 20.32, pitchIn: "0.8\""},
};
// Standard SMD (chip) packages: imperial code -> metric code, size (mm), power (W).
const SMD_PACKAGES = {
"0201": {metric: "0603", l: 0.6, w: 0.3, power: 0.05},
"0402": {metric: "1005", l: 1.0, w: 0.5, power: 0.063},
"0603": {metric: "1608", l: 1.6, w: 0.8, power: 0.1},
"0805": {metric: "2012", l: 2.0, w: 1.25, power: 0.125},
"1206": {metric: "3216", l: 3.2, w: 1.6, power: 0.25},
"1210": {metric: "3225", l: 3.2, w: 2.5, power: 0.33},
"2010": {metric: "5025", l: 5.0, w: 2.5, power: 0.5},
"2512": {metric: "6332", l: 6.3, w: 3.2, power: 1.0},
};
// Common lead pitches for radial ceramic capacitors.
const CAP_PITCHES = {
"2.5": "0.1\"",
"2.54": "0.1\"",
"5": "0.2\"",
"5.08": "0.2\"",
"7.5": "",
"10": "",
"15": "",
};
// Lead length presets (extra pixels the leads extend below the body).
const CAP_LEAD_LENGTHS = {short: 48, medium: 84, long: 130};
// Axial resistor lead length presets (pixels each lead extends beyond the body; medium = original look).
const RESISTOR_LEAD_LENGTHS = {short: 40, medium: 90, long: 150};
const DIM_COLOR = "#6b7280";
export default class extends Controller {
static targets = [
"resistorSvg", "bandSelects", "resistorResult", "resistorValueInput", "resistorBodyColor",
"resistorPower", "resistorSpec",
"capValueInput", "capCodeInput", "capTolerance", "capSvg", "capResult", "capBodyColor",
"capPitch", "capDiameter", "capVoltage", "capSpec", "capShape", "capLead",
"smdValueInput", "smdCode3", "smdCode4", "smdEia96",
"smdSvg", "smdResult", "smdBodyColor", "smdPackage", "smdSpec",
"smdIndValueInput", "smdIndCode", "smdIndPackage", "smdIndBodyColor", "smdIndSvg", "smdIndSpec",
"smdCapValueInput", "smdCapPackage", "smdCapBodyColor", "smdCapVoltage", "smdCapTolerance", "smdCapSvg", "smdCapSpec",
"indBandSelects", "indValueInput", "indSvg", "indResult", "indBodyColor",
"previewInput",
];
static values = {
endpoint: String,
csrf: String,
prefillOhms: { type: Number, default: 0 },
prefillFarads: { type: Number, default: 0 },
};
connect() {
this.bandCount = 5;
this.renderBandSelects();
this.updateCapSpec();
// When opened from a part, pre-fill with the part's detected resistance/capacitance;
// otherwise fall back to illustrative demo values so nothing starts empty. Each section
// is isolated so a failure in one can't block the others (and surfaces on screen).
const partOhms = this.prefillOhmsValue > 0 ? this.prefillOhmsValue : null;
const partFarads = this.prefillFaradsValue > 0 ? this.prefillFaradsValue : null;
const resistorOhms = partOhms ?? 4700;
if (!this.setBandsFromValue(resistorOhms, 1) && this.hasResistorValueInputTarget) {
// Not representable as standard color bands: show it in the value input instead.
this.resistorValueInputTarget.value = this.formatOhms(resistorOhms);
}
this.updateResistor();
try {
this.smdMarking = "code3";
// 10 kΩ demo has a clean code in every representation (103 / 1002 / 01C) so the
// EIA-96 field isn't "—" on first open, unlike an E24 value such as 4.7 kΩ.
this.smdOhms = partOhms ?? 10000;
this.setSmdFields(this.smdOhms, null);
this.redrawSmd();
} catch (e) {
console.error("value_calc: SMD init failed", e);
if (this.hasSmdResultTarget) this.smdResultTarget.textContent = "error: " + e.message;
}
try {
this.capPf = (partFarads ?? 100e-9) * 1e12;
this.setCapFields(this.capPf, null);
this.redrawCap();
} catch (e) {
console.error("value_calc: capacitor init failed", e);
if (this.hasCapResultTarget) this.capResultTarget.textContent = "error: " + e.message;
}
try {
//THT inductor colour-band tab: 4 bands read as µH, demo value 100 µH.
this.indBandCount = 4;
if (this.hasIndBandSelectsTarget) {
this.renderBandSelects(this.indBandSelectsTarget, this.indBandCount, "updateInductor");
this.setBandsFromValue(100, 10, this.indBandSelectsTarget, this.indBandCount);
this.updateInductor();
}
} catch (e) {
console.error("value_calc: THT inductor init failed", e);
}
try {
//The SMD inductor tab starts on an illustrative value so it isn't empty on first open.
this.syncSmdInductor();
} catch (e) {
console.error("value_calc: SMD inductor init failed", e);
}
try {
this.syncSmdCap();
} catch (e) {
console.error("value_calc: SMD capacitor init failed", e);
}
// Jump to the tab matching the part's detected type.
if (partFarads !== null && partOhms === null) {
this.activateTab("vc-capacitor-tab");
} else if (partOhms !== null) {
this.activateTab("vc-resistor-tab");
}
}
/** Activates a Bootstrap tab by its button id (no-op if unavailable). */
activateTab(id) {
const btn = document.getElementById(id);
if (!btn) {
return;
}
try {
btn.click();
} catch (e) {
/* ignore — the default tab is fine */
}
}
/**
* Public helper used by the bulk generator: renders the picture for the given component and
* returns its SVG markup (without any tab interaction). `type` is 'resistor', 'smd_resistor'
* or 'capacitor'; `value` is ohms (resistors) or farads (capacitors); `options` may carry
* {voltage, package, tolerance}.
*/
generateSvg(type, value, options = {}) {
try {
if (options.bodyColor) {
if (this.hasCapBodyColorTarget) {
this.capBodyColorTarget.value = options.bodyColor;
}
if (this.hasSmdBodyColorTarget) {
this.smdBodyColorTarget.value = options.bodyColor;
}
if (this.hasResistorBodyColorTarget) {
this.resistorBodyColorTarget.value = options.bodyColor;
}
}
if (type === "capacitor") {
if (this.hasCapDiameterTarget && options.diameter > 0) {
this.capDiameterTarget.value = String(options.diameter);
}
if (this.hasCapPitchTarget && options.pitch) {
this.capPitchTarget.value = String(options.pitch);
}
if (this.hasCapVoltageTarget) {
this.capVoltageTarget.value = options.voltage > 0 ? String(options.voltage) : "";
}
if (this.hasCapShapeTarget && options.shape) {
this.capShapeTarget.value = options.shape;
}
if (this.hasCapLeadTarget && options.leadLength) {
this.capLeadTarget.value = options.leadLength;
}
if (this.hasCapToleranceTarget) {
this.capToleranceTarget.value = options.tolerance ? this.capToleranceLetterForPercent(options.tolerance) : "";
}
this.updateCapSpec();
this.capPf = value * 1e12;
this.setCapFields(this.capPf, null);
this.redrawCap();
return this.hasCapSvgTarget ? this.capSvgTarget.innerHTML.trim() : "";
}
if (type === "smd_resistor" || type === "smd") {
if (options.package && this.hasSmdPackageTarget) {
this.smdPackageTarget.value = options.package;
}
//On SMD resistors the tolerance is expressed by the marking system: 1% (or tighter)
//uses the 4-digit code, looser tolerances use the 3-digit code. Fall back to 3-digit
//if the 4-digit code can't represent the value.
const wants4 = options.tolerance != null && options.tolerance <= 1;
this.smdMarking = wants4 && this.ohmsTo4Digit(value) ? "code4" : "code3";
this.smdOhms = value;
this.smdTolerance = options.tolerance;
this.smdVoltage = options.voltage;
this.setSmdFields(value, null);
this.redrawSmd();
return this.hasSmdSvgTarget ? this.smdSvgTarget.innerHTML.trim() : "";
}
if (type === "inductor") {
//The inductor colour code is the resistor code read as microhenries.
if (options.leadLength) {
this.resistorLead = options.leadLength;
}
const desiredBands = (options.tolerance != null && options.tolerance <= 2) ? 5 : 4;
if (this.hasBandSelectsTarget && this.bandCount !== desiredBands) {
this.bandCount = desiredBands;
this.renderBandSelects();
}
this.setBandsFromValue(value / 1e-6, options.tolerance ?? 10);
this.drawInductor(this.selectedColors(), value, options.bodyColor, null, null, {tolerance: options.tolerance, voltage: options.voltage});
return this.hasResistorSvgTarget ? this.resistorSvgTarget.innerHTML.trim() : "";
}
if (type === "smd_inductor") {
//Molded/shielded SMD power inductor: the printed marking is the 3-digit EIA code in µH.
const marking = this.henriesToInductorCode(value / 1e-6);
const t = this.hasSmdIndSvgTarget ? this.smdIndSvgTarget : this.smdSvgTarget;
this.drawSmdInductor(t, marking, value, options);
return t ? t.innerHTML.trim() : "";
}
if (type === "smd_capacitor") {
//MLCC chip: unmarked, value shown as a caption. `value` is farads.
this.smdCapPf = value * 1e12;
const t = this.hasSmdCapSvgTarget ? this.smdCapSvgTarget : this.capSvgTarget;
this.drawSmdCapacitor(t, {package: options.package || "0805", bodyColor: options.bodyColor, voltage: options.voltage, tolerance: options.tolerance});
return t ? t.innerHTML.trim() : "";
}
if (type === "diode") {
//Bulk-only type (no interactive tab): draws into the shared scratch target, like the inductor.
this.drawDiode(this.resistorSvgTarget, options.subtype || "diode", value, options);
return this.hasResistorSvgTarget ? this.resistorSvgTarget.innerHTML.trim() : "";
}
// Resistor (through-hole colour bands)
this.resistorVoltage = options.voltage; //shown on the picture when the part lists a rated voltage
if (this.hasResistorPowerTarget && options.power) {
this.resistorPowerTarget.value = this.resistorPowerKey(options.power);
}
if (options.leadLength) {
this.resistorLead = options.leadLength;
}
//Band count follows real convention: 6 bands when a temp coefficient is given,
//5 bands for tight tolerance (≤2 %), otherwise 4 bands.
const desiredBands = options.ppm ? 6 : ((options.tolerance != null && options.tolerance <= 2) ? 5 : 4);
if (this.hasBandSelectsTarget && this.bandCount !== desiredBands) {
this.bandCount = desiredBands;
this.renderBandSelects();
}
if (!this.setBandsFromValue(value, options.tolerance ?? 5) && this.hasResistorValueInputTarget) {
this.resistorValueInputTarget.value = this.formatOhms(value);
}
if (options.ppm && this.hasBandSelectsTarget) {
this.applyTempBand(options.ppm);
}
this.updateResistor();
return this.hasResistorSvgTarget ? this.resistorSvgTarget.innerHTML.trim() : "";
} catch (e) {
console.error("value_calc: generateSvg failed", e);
return "";
}
}
/**
* Empties the shared preview SVG targets. Bulk previews call this after copying each generated
* SVG into its own cell, so the last-rendered SVG isn't left here with an id that then collides
* with the copy in the (visible) cell — which made the last preview render unclipped.
*/
clearScratchSvg() {
if (this.hasCapSvgTarget) {
this.capSvgTarget.innerHTML = "";
}
if (this.hasSmdSvgTarget) {
this.smdSvgTarget.innerHTML = "";
}
if (this.hasResistorSvgTarget) {
this.resistorSvgTarget.innerHTML = "";
}
if (this.hasSmdIndSvgTarget) {
this.smdIndSvgTarget.innerHTML = "";
}
if (this.hasSmdCapSvgTarget) {
this.smdCapSvgTarget.innerHTML = "";
}
}
/**
* Posts the currently shown SVG of the chosen picture to the server so it gets attached to
* the part. Uses a background request so the modal can close without navigating away (which
* would otherwise trigger the browser's "unsaved changes" prompt and lose the edit form).
*/
attachToPart(event) {
const active = this.element.querySelector(".tab-pane.active");
if (!active) {
return;
}
const containers = {
"vc-resistor": this.hasResistorSvgTarget ? this.resistorSvgTarget : null,
"vc-capacitor": this.hasCapSvgTarget ? this.capSvgTarget : null,
"vc-smd": this.hasSmdSvgTarget ? this.smdSvgTarget : null,
"vc-inductor": this.hasIndSvgTarget ? this.indSvgTarget : null,
"vc-smdind": this.hasSmdIndSvgTarget ? this.smdIndSvgTarget : null,
"vc-smdcap": this.hasSmdCapSvgTarget ? this.smdCapSvgTarget : null,
};
const container = containers[active.id];
const svg = container ? container.innerHTML.trim() : "";
const name = active.dataset.vcName || "Generated image";
this.doAttach(svg, name, event.currentTarget);
}
/** Sends one SVG to the server to be attached to the part (background request, no navigation). */
doAttach(svg, name, btn) {
if (!this.hasEndpointValue) {
return;
}
if (!svg.includes("<svg")) {
AlertSwal.fire({title: trans("tools.value_calc.attach.nothing")});
return;
}
const preview = this.hasPreviewInputTarget ? this.previewInputTarget.checked : true;
const body = new FormData();
body.append("svg", svg);
body.append("name", name || "");
body.append("preview", preview ? "1" : "0");
body.append("_token", this.csrfValue);
if (btn) {
btn.disabled = true;
}
fetch(this.endpointValue, {
method: "POST",
body,
headers: {"X-Requested-With": "XMLHttpRequest"},
})
.then((r) => r.json().then((data) => ({ok: r.ok, data})))
.then(({ok, data}) => {
if (btn) {
btn.disabled = false;
}
if (ok && data && data.success) {
this.finishAttach(data.message);
} else {
AlertSwal.fire({title: (data && data.message) || trans("tools.value_calc.invalid_input")});
}
})
.catch(() => {
if (btn) {
btn.disabled = false;
}
AlertSwal.fire({title: trans("tools.value_calc.invalid_input")});
});
}
/**
* After a successful attach: close the generator modal (via its dismiss control, which works
* even when Bootstrap isn't exposed globally), then either reload the read-only part page so
* the new picture shows, or — on the edit form — just toast so unsaved changes aren't lost.
*/
finishAttach(message) {
const modalEl = document.getElementById("vcGenerateModal");
if (modalEl) {
const dismiss = modalEl.querySelector("[data-bs-dismiss='modal']");
if (dismiss) {
dismiss.click();
} else {
window.bootstrap?.Modal?.getInstance(modalEl)?.hide();
}
}
// On the edit page, refresh just the attachment list via its Turbo frame: the new image
// shows and the form includes it (so orphanRemoval can't delete it on the next save) —
// without a full-page reload or the unsaved-changes prompt. Elsewhere (part info page)
// just reload so the new picture appears.
const frame = document.getElementById("part-attachments-frame");
if (frame) {
if (frame.getAttribute("src") && typeof frame.reload === "function") {
frame.reload();
} else {
frame.setAttribute("src", window.location.href.split("#")[0]);
}
AlertSwal.fire({title: message, icon: "success", timer: 2000, showConfirmButton: false});
} else {
window.location.reload();
}
}
/*
* ---------------------------------------------------------------
* Resistor color code
* ---------------------------------------------------------------
*/
changeBandCount(event) {
// Read the currently shown value BEFORE changing bandCount: computeResistance() reads
// this.bandCount against the still-old (not yet re-rendered) selects, so it must run while
// both are still in sync — otherwise the role count no longer matches the select count and
// the value is silently lost (bands reset to their defaults instead of being preserved).
const current = this.computeResistance();
this.bandCount = parseInt(event.target.value, 10);
this.renderBandSelects();
if (current && current.ohms > 0) {
this.setBandsFromValue(current.ohms, current.tolerance);
}
this.updateResistor();
}
/** Returns the list of band "roles" for the given band count (defaults to the resistor tab's). */
bandRoles(count = this.bandCount) {
if (count === 4) {
return ["digit", "digit", "multiplier", "tolerance"];
}
if (count === 6) {
return ["digit", "digit", "digit", "multiplier", "tolerance", "temp"];
}
return ["digit", "digit", "digit", "multiplier", "tolerance"];
}
/** Colors that are valid for a given band role. */
colorsForRole(role) {
return Object.keys(RESISTOR_COLORS).filter((name) => RESISTOR_COLORS[name][role] !== null);
}
labelForRole(role) {
return {
digit: "tools.value_calc.resistor.band_digit",
multiplier: "tools.value_calc.resistor.band_multiplier",
tolerance: "tools.value_calc.resistor.band_tolerance",
temp: "tools.value_calc.resistor.band_temp",
}[role];
}
renderBandSelects(target = this.bandSelectsTarget, count = this.bandCount, action = "updateResistor") {
const roles = this.bandRoles(count);
let html = "";
//Each band gets an ordinal prefix ("1st", "2nd", …) so the three identical "Digit" bands are
//no longer ambiguous — the number matches reading the physical part left-to-right.
roles.forEach((role, index) => {
const options = this.colorsForRole(role)
.map((name) => `<option value="${name}">${this.colorLabel(name)}</option>`)
.join("");
const col = roles.length >= 6 ? "col" : "col-sm";
const label = `${this.ordinal(index + 1)} ${trans("tools.value_calc.band")} · ${trans(this.labelForRole(role))}`;
html += `
<div class="${col} mb-2">
<label class="form-label small text-muted mb-1">${label}</label>
<select class="form-select" data-band-index="${index}"
data-action="${this.identifier}#${action}">${options}</select>
</div>`;
});
target.innerHTML = html;
}
/** English ordinal for a small band index (1 -> "1st", 2 -> "2nd", 3 -> "3rd", 4 -> "4th", …). */
ordinal(n) {
if (n === 1) {
return "1st";
}
if (n === 2) {
return "2nd";
}
if (n === 3) {
return "3rd";
}
return `${n}th`;
}
/**
* Builds the " · 50 V · ±10%" spec suffix appended to the value printed on a generated picture,
* so the image also carries the rated voltage (caps) and tolerance when they are known. Parts
* that don't apply are simply omitted.
*/
specSuffix(opts = {}) {
let s = "";
const v = opts.voltage !== undefined && opts.voltage !== null ? parseFloat(opts.voltage) : NaN;
if (Number.isFinite(v) && v > 0) {
s += ` · ${this.trimNumber(v)} V`;
}
const t = opts.tolerance !== undefined && opts.tolerance !== null && opts.tolerance !== "" ? parseFloat(opts.tolerance) : NaN;
if (Number.isFinite(t) && t > 0) {
s += ` · ±${this.trimNumber(t)}%`;
}
return s;
}
colorLabel(name) {
return trans("tools.value_calc.color." + name);
}
/** Reads the currently selected color of every band select in the given target. */
selectedColorsFrom(target) {
return Array.from(target.querySelectorAll("select")).map((sel) => sel.value);
}
/** Reads the currently selected color of every resistor band select. */
selectedColors() {
return this.selectedColorsFrom(this.bandSelectsTarget);
}
/** Reads a band-coded value (digits × multiplier), tolerance % and temp ppm from a selects target. */
computeBandValue(target, count) {
const roles = this.bandRoles(count);
const colors = this.selectedColorsFrom(target);
if (colors.length !== roles.length) {
return null;
}
let digits = "";
let multiplier = 1;
let tolerance = null;
let temp = null;
roles.forEach((role, i) => {
const color = RESISTOR_COLORS[colors[i]];
if (role === "digit") {
digits += color.digit.toString();
} else if (role === "multiplier") {
multiplier = color.multiplier;
} else if (role === "tolerance") {
tolerance = color.tolerance;
} else if (role === "temp") {
temp = color.temp;
}
});
return {value: parseInt(digits, 10) * multiplier, tolerance, temp};
}
computeResistance() {
const r = this.computeBandValue(this.bandSelectsTarget, this.bandCount);
return r === null ? null : {ohms: r.value, tolerance: r.tolerance, temp: r.temp};
}
updateResistor() {
const res = this.computeResistance();
if (!res) {
return;
}
let text = this.formatOhms(res.ohms);
if (res.tolerance !== null) {
text += ` ±${res.tolerance}%`;
}
if (res.temp !== null) {
text += ` · ${res.temp} ppm/K`;
}
if (this.hasResistorResultTarget) {
this.resistorResultTarget.textContent = text;
}
this.drawResistor(this.selectedColors());
}
/**
* Determine the band colors representing the given resistance and write
* them into the selects.
*/
setBandsFromValue(value, tolerance, target = this.bandSelectsTarget, count = this.bandCount) {
if (!(value > 0)) {
return false;
}
const numDigits = count === 4 ? 2 : 3;
// Normalize the value into <numDigits> significant figures + power of ten
let exp = Math.floor(Math.log10(value)) - (numDigits - 1);
let digits = Math.round(value / Math.pow(10, exp));
if (digits >= Math.pow(10, numDigits)) {
digits = Math.round(digits / 10);
exp += 1;
}
const multiplier = Math.pow(10, exp);
// Find a color whose multiplier matches (within float tolerance)
const multiplierColor = Object.keys(RESISTOR_COLORS).find(
(name) => RESISTOR_COLORS[name].multiplier !== null
&& Math.abs(RESISTOR_COLORS[name].multiplier - multiplier) < multiplier * 1e-6
);
if (!multiplierColor) {
// Value out of representable range
return false;
}
const digitStr = digits.toString().padStart(numDigits, "0");
const roles = this.bandRoles(count);
const selects = target.querySelectorAll("select");
let digitIdx = 0;
roles.forEach((role, i) => {
if (role === "digit") {
selects[i].value = this.colorForDigit(parseInt(digitStr[digitIdx], 10));
digitIdx += 1;
} else if (role === "multiplier") {
selects[i].value = multiplierColor;
} else if (role === "tolerance" && tolerance !== null && tolerance !== undefined) {
const tolColor = this.colorForTolerance(tolerance);
if (tolColor) {
selects[i].value = tolColor;
}
}
});
return true;
}
colorForDigit(digit) {
return Object.keys(RESISTOR_COLORS).find((name) => RESISTOR_COLORS[name].digit === digit);
}
colorForTolerance(tolerance) {
return Object.keys(RESISTOR_COLORS).find(
(name) => RESISTOR_COLORS[name].tolerance === tolerance
);
}
/** Snaps a wattage to the nearest defined resistor power rating key (e.g. 0.3 -> "0.25"). */
resistorPowerKey(watts) {
const keys = Object.keys(RESISTOR_POWERS).map(Number);
let best = keys[0];
for (const k of keys) {
if (Math.abs(k - watts) < Math.abs(best - watts)) {
best = k;
}
}
return String(best);
}
/** The band colour whose temperature coefficient is nearest to the given ppm/K value. */
colorForTemp(ppm) {
let best = null;
let bestDelta = Infinity;
for (const name of Object.keys(RESISTOR_COLORS)) {
const t = RESISTOR_COLORS[name].temp;
if (t === null || t === undefined) {
continue;
}
const delta = Math.abs(t - ppm);
if (delta < bestDelta) {
bestDelta = delta;
best = name;
}
}
return best;
}
/** Sets the temperature-coefficient band (6-band resistors) to the colour matching the ppm value. */
applyTempBand(ppm) {
const roles = this.bandRoles();
const selects = this.bandSelectsTarget.querySelectorAll("select");
const tempColor = this.colorForTemp(ppm);
roles.forEach((role, i) => {
if (role === "temp" && tempColor && selects[i]) {
selects[i].value = tempColor;
}
});
}
applyResistorValue() {
const raw = this.resistorValueInputTarget.value;
const ohms = this.parseValue(raw, "R");
if (ohms === null || !(ohms > 0)) {
this.resistorValueInputTarget.classList.add("is-invalid");
return;
}
// Keep whatever tolerance is currently selected, default to 1%
const current = this.computeResistance();
const tol = current && current.tolerance !== null ? current.tolerance : 1;
if (!this.setBandsFromValue(ohms, tol)) {
this.resistorValueInputTarget.classList.add("is-invalid");
return;
}
this.resistorValueInputTarget.classList.remove("is-invalid");
this.updateResistor();
}
applyResistorBodyColor(event) {
if (this.hasResistorBodyColorTarget) {
this.resistorBodyColorTarget.value = event.currentTarget.dataset.color;
}
this.updateResistor();
}
/*
* ---------------------------------------------------------------
* THT inductor colour code (interactive) — same bands as a resistor, read as µH.
* ---------------------------------------------------------------
*/
changeIndBandCount(event) {
//Same ordering requirement as changeBandCount(): read the value while the (still-old) DOM
//and the (still-old) band count agree, before switching the count and re-rendering.
const current = this.computeBandValue(this.indBandSelectsTarget, this.indBandCount);
this.indBandCount = parseInt(event.target.value, 10);
this.renderBandSelects(this.indBandSelectsTarget, this.indBandCount, "updateInductor");
if (current && current.value > 0) {
this.setBandsFromValue(current.value, current.tolerance, this.indBandSelectsTarget, this.indBandCount);
}
this.updateInductor();
}
/** Reads the inductor band colours, computes the µH value and redraws the barrel. */
updateInductor() {
if (!this.hasIndSvgTarget) {
return;
}
const r = this.computeBandValue(this.indBandSelectsTarget, this.indBandCount);
if (!r) {
return;
}
const henries = r.value * 1e-6; //the band value is read in microhenries
let text = this.formatHenries(henries);
if (r.tolerance !== null) {
text += ` ±${r.tolerance}%`;
}
if (this.hasIndResultTarget) {
this.indResultTarget.textContent = text;
}
const color = this.hasIndBodyColorTarget ? this.indBodyColorTarget.value : null;
this.drawInductor(this.selectedColorsFrom(this.indBandSelectsTarget), henries, color, this.indSvgTarget, "medium", {tolerance: r.tolerance});
}
/** Sets the inductor bands from a typed inductance (bare number = µH; accepts nH/µH/mH/H). */
applyInductorValue() {
const raw = (this.hasIndValueInputTarget ? this.indValueInputTarget.value : "").trim();
const m = raw.match(/^([\d.]+)\s*(p|n|u|µ|m)?\s*h?$/i);
if (!m) {
if (this.hasIndValueInputTarget) {
this.indValueInputTarget.classList.add("is-invalid");
}
return;
}
const num = parseFloat(m[1]);
const factors = {p: 1e-12, n: 1e-9, u: 1e-6, "µ": 1e-6, m: 1e-3};
const henries = m[2] ? num * factors[m[2].toLowerCase()] : num * 1e-6; //bare number = µH
const uH = henries / 1e-6;
const current = this.computeBandValue(this.indBandSelectsTarget, this.indBandCount);
const tol = current && current.tolerance !== null ? current.tolerance : 10;
if (!(uH > 0) || !this.setBandsFromValue(uH, tol, this.indBandSelectsTarget, this.indBandCount)) {
if (this.hasIndValueInputTarget) {
this.indValueInputTarget.classList.add("is-invalid");
}
return;
}
if (this.hasIndValueInputTarget) {
this.indValueInputTarget.classList.remove("is-invalid");
}
this.updateInductor();
}
applyIndBodyColor(event) {
if (this.hasIndBodyColorTarget) {
this.indBodyColorTarget.value = event.currentTarget.dataset.color;
}
this.updateInductor();
}
/** Draws a 3D-shaded axial resistor SVG with bands and dimension callouts. */
drawResistor(colors) {
const uid = this.svgId();
const margin = 6;
const leadExt = RESISTOR_LEAD_LENGTHS[this.resistorLeadValue()] ?? RESISTOR_LEAD_LENGTHS.medium;
const bodyW = 208;
const bodyH = 66;
const bodyX = margin + leadExt;
const width = bodyW + 2 * (margin + leadExt);
const height = 205;
const cy = 60;
const bodyY = cy - bodyH / 2;
const bodyBottom = bodyY + bodyH;
// Distribute the bands across the body, leaving the tolerance band set apart
const n = colors.length;
const bandW = 16;
const leftPad = 22;
const rightPad = 32; // extra gap before the tolerance band
const usable = bodyW - leftPad - rightPad;
const step = usable / (n - 1);
let bands = "";
colors.forEach((name, i) => {
const c = RESISTOR_COLORS[name];
// Put the last band (tolerance/temp) towards the right end
let x = bodyX + leftPad + i * step;
if (i === n - 1) {
x = bodyX + bodyW - rightPad + 8;
}
bands += `<rect x="${x - bandW / 2}" y="${bodyY - 2}" width="${bandW}" height="${bodyH + 4}" fill="${c.hex}"/>`;
});
const body = this.safeColor(this.bodyColor(this.hasResistorBodyColorTarget ? this.resistorBodyColorTarget : null, "#d8c7a0"), "#d8c7a0");
const dim = RESISTOR_POWERS[this.resistorPowerValue()];
//The bands are the "real" value encoding, but printing the decoded value too (like every
//other drawing in this tool) makes the picture self-explanatory on its own.
const res = this.computeResistance();
const resistanceLabel = res ? this.formatOhms(res.ohms) + this.specSuffix({voltage: this.resistorVoltage, tolerance: res.tolerance}) : "";
const callouts =
this.dimH(bodyX, bodyX + bodyW, bodyBottom + 16, `L ${this.formatMm(dim.len)}`)
+ this.dimH(margin + 4, width - margin - 4, height - 34, `pitch ${this.formatMm(dim.pitch)} (${dim.pitchIn})`)
+ this.dimV(bodyY, bodyBottom, bodyX + bodyW + 28, `${this.formatMm(dim.dia)}`, bodyX + bodyW)
+ (resistanceLabel ? `<text x="${width / 2}" y="${height - 10}" text-anchor="middle" font-family="monospace" font-size="16" font-weight="700" fill="#3a4149">${resistanceLabel}</text>` : "");
const svg = `
<svg viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg" style="max-width: 460px; width: 100%; height: auto;">
<defs>
${this.leadGradient(uid)}
${this.cylinderGradient(uid)}
${this.endVignetteGradient(uid)}
${this.blurFilter(uid)}
<clipPath id="${uid}clip"><rect x="${bodyX}" y="${bodyY}" width="${bodyW}" height="${bodyH}" rx="20" ry="20"/></clipPath>
${this.shadowFilter(uid)}
</defs>
<g>
<rect x="${margin}" y="${cy - 5}" width="${width - 2 * margin}" height="10" rx="5" fill="url(#${uid}lead)"/>
<g clip-path="url(#${uid}clip)">
<rect x="${bodyX}" y="${bodyY}" width="${bodyW}" height="${bodyH}" fill="${body}"/>
${bands}
<rect x="${bodyX}" y="${bodyY}" width="${bodyW}" height="${bodyH}" fill="url(#${uid}cyl)"/>
<rect x="${bodyX}" y="${bodyY}" width="${bodyW}" height="${bodyH}" fill="url(#${uid}vig)"/>
<ellipse cx="${width / 2}" cy="${bodyY + bodyH * 0.26}" rx="${bodyW * 0.44}" ry="4.5" fill="#ffffff" opacity="0.45" filter="url(#${uid}blur)"/>
</g>
<rect x="${bodyX}" y="${bodyY}" width="${bodyW}" height="${bodyH}" rx="20" ry="20" fill="none" stroke="#00000055" stroke-width="1"/>
</g>
${callouts}
</svg>`;
this.resistorSvgTarget.innerHTML = svg;
if (this.hasResistorSpecTarget) {
this.resistorSpecTarget.textContent =
`${dim.label} · ${this.formatMm(dim.len)} ×${this.formatMm(dim.dia)} · pitch ${this.formatMm(dim.pitch)} (${dim.pitchIn})`;
}
}
/**
* Draws a molded axial inductor: the same colour-band cylinder as a resistor, but a fatter
* green body and a henry value label. The bands are set by the shared resistor band engine
* (the inductor colour code is identical, read as microhenries).
*/
drawInductor(colors, henries, bodyColorOverride, target = null, leadKey = null, spec = {}) {
const tgt = target || this.resistorSvgTarget;
const uid = this.svgId();
const margin = 6;
const leadExt = RESISTOR_LEAD_LENGTHS[leadKey || this.resistorLeadValue()] ?? RESISTOR_LEAD_LENGTHS.medium;
const bodyW = 168;
const bodyH = 78;
const bodyX = margin + leadExt;
const width = bodyW + 2 * (margin + leadExt);
const height = 196;
const cy = 62;
const bodyY = cy - bodyH / 2;
const bodyBottom = bodyY + bodyH;
const n = colors.length;
const bandW = 16;
const leftPad = 22;
const rightPad = 32;
const usable = bodyW - leftPad - rightPad;
const step = usable / (n - 1);
let bands = "";
colors.forEach((name, i) => {
const c = RESISTOR_COLORS[name];
let x = bodyX + leftPad + i * step;
if (i === n - 1) {
x = bodyX + bodyW - rightPad + 8;
}
bands += `<rect x="${x - bandW / 2}" y="${bodyY - 2}" width="${bandW}" height="${bodyH + 4}" fill="${c.hex}"/>`;
});
const colorTarget = bodyColorOverride ? {value: bodyColorOverride} : (this.hasResistorBodyColorTarget ? this.resistorBodyColorTarget : null);
const body = this.safeColor(this.bodyColor(colorTarget, "#2f6f4c"), "#2f6f4c");
//A colour-coded THT inductor's physical size isn't implied by its inductance, so we don't
//draw a (fake) dimension callout here — just the decoded value below the barrel.
const callouts =
`<text x="${width / 2}" y="${height - 12}" text-anchor="middle" font-family="monospace" font-size="16" font-weight="700" fill="#3a4149">${this.formatHenries(henries)}${this.specSuffix(spec)}</text>`;
tgt.innerHTML = `
<svg viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg" style="max-width: 460px; width: 100%; height: auto;">
<defs>
${this.leadGradient(uid)}
${this.cylinderGradient(uid)}
${this.endVignetteGradient(uid)}
${this.blurFilter(uid)}
<clipPath id="${uid}clip"><rect x="${bodyX}" y="${bodyY}" width="${bodyW}" height="${bodyH}" rx="30" ry="30"/></clipPath>
${this.shadowFilter(uid)}
</defs>
<g>
<rect x="${margin}" y="${cy - 5}" width="${width - 2 * margin}" height="10" rx="5" fill="url(#${uid}lead)"/>
<g clip-path="url(#${uid}clip)">
<rect x="${bodyX}" y="${bodyY}" width="${bodyW}" height="${bodyH}" fill="${body}"/>
${bands}
<rect x="${bodyX}" y="${bodyY}" width="${bodyW}" height="${bodyH}" fill="url(#${uid}cyl)"/>
<rect x="${bodyX}" y="${bodyY}" width="${bodyW}" height="${bodyH}" fill="url(#${uid}vig)"/>
<ellipse cx="${width / 2}" cy="${bodyY + bodyH * 0.24}" rx="${bodyW * 0.44}" ry="5" fill="#ffffff" opacity="0.4" filter="url(#${uid}blur)"/>
</g>
<rect x="${bodyX}" y="${bodyY}" width="${bodyW}" height="${bodyH}" rx="30" ry="30" fill="none" stroke="#00000055" stroke-width="1"/>
</g>
${callouts}
</svg>`;
}
/** Human-readable inductance: nH / µH / mH / H. */
formatHenries(h) {
if (h >= 1) {
return `${this.trimNumber(h)} H`;
}
if (h >= 1e-3) {
return `${this.trimNumber(h / 1e-3)} mH`;
}
if (h >= 1e-6) {
return `${this.trimNumber(h / 1e-6)} µH`;
}
return `${this.trimNumber(h / 1e-9)} nH`;
}
/**
* The marking printed on an SMD inductor, read in microhenries: R-notation below 10 µH
* (4.7 -> 4R7, 0.47 -> R47) and the 3-digit EIA code from 10 µH upwards (100 -> 101, 22 -> 220).
*/
henriesToInductorCode(uH) {
if (!(uH > 0)) {
return "";
}
if (uH < 10) {
let s = parseFloat(uH.toFixed(2)).toString();
if (!s.includes(".")) {
s += ".0";
}
return s.startsWith("0.") ? "R" + s.slice(2) : s.replace(".", "R");
}
let exp = Math.floor(Math.log10(uH)) - 1;
let significant = Math.round(uH / Math.pow(10, exp));
if (significant >= 100) {
significant = Math.round(significant / 10);
exp += 1;
}
if (exp < 0) {
exp = 0;
}
return significant.toString().padStart(2, "0") + exp.toString();
}
/**
* Draws a molded / shielded SMD power inductor: a dark rounded ferrite block with a soft domed
* highlight, metal end terminations and the printed µH marking. Sized from the chip package.
*/
drawSmdInductor(target, marking, henries, options = {}) {
const uid = this.svgId();
const w = 300;
const pkgKey = SMD_PACKAGES[options.package] ? options.package : "1210";
const pkg = SMD_PACKAGES[pkgKey];
//Simple top-down chip with the value code printed on it (same style as the SMD resistor); the
//body follows the package L:W ratio so a 1210 looks square and a 0402 a 2:1 rectangle.
const bodyW = Math.round(122 + 66 * (pkg.l - 0.6) / (6.3 - 0.6));
const aspect = pkg.l / pkg.w;
const bodyH = Math.max(48, Math.min(140, Math.round(bodyW / aspect)));
const capW = Math.max(14, Math.round(bodyW * 0.14));
const cx = w / 2;
const bodyX = Math.round(cx - bodyW / 2);
const bodyY = Math.round(84 - bodyH / 2);
const bodyBottom = bodyY + bodyH;
const cy = bodyY + bodyH / 2;
const innerX = bodyX + capW;
const innerW = bodyW - 2 * capW;
const fill = this.safeColor(options.bodyColor, "#33363d");
const textColor = this.contrastColor(fill);
const fontSize = Math.max(15, Math.min(38, Math.round(bodyH * 0.5), Math.round(innerW * 1.6 / Math.max(3, marking.length))));
const callouts =
this.dimH(bodyX, bodyX + bodyW, bodyBottom + 18, `L ${this.formatMm(pkg.l)}`)
+ this.dimV(bodyY, bodyBottom, bodyX + bodyW + 16, `W ${this.formatMm(pkg.w)}`, bodyX + bodyW);
const h = bodyBottom + 54;
const valueCaption = `<text x="${cx}" y="${h - 12}" text-anchor="middle" font-family="monospace" font-size="16" font-weight="700" fill="#3a4149">${this.formatHenries(henries)}${this.specSuffix(options)}</text>`;
target.innerHTML = `
<svg viewBox="0 0 ${w} ${h}" xmlns="http://www.w3.org/2000/svg" style="max-width: 300px; width: 100%; height: auto;">
<defs>
${this.metalGradient(uid)}
${this.glossGradient(uid)}
<clipPath id="${uid}clip"><rect x="${bodyX}" y="${bodyY}" width="${bodyW}" height="${bodyH}" rx="10" ry="10"/></clipPath>
${this.shadowFilter(uid)}
</defs>
<g>
<rect x="${bodyX}" y="${bodyY}" width="${bodyW}" height="${bodyH}" rx="10" ry="10" fill="url(#${uid}metal)" stroke="#00000055" stroke-width="1" filter="url(#${uid}shadow)"/>
<g clip-path="url(#${uid}clip)">
<rect x="${innerX}" y="${bodyY}" width="${innerW}" height="${bodyH}" fill="${fill}"/>
<rect x="${innerX}" y="${bodyY}" width="${innerW}" height="${bodyH}" fill="url(#${uid}gloss)"/>
<rect x="${innerX - 2}" y="${bodyY}" width="3" height="${bodyH}" fill="#000000" opacity="0.28"/>
<rect x="${innerX + innerW - 1}" y="${bodyY}" width="3" height="${bodyH}" fill="#000000" opacity="0.28"/>
</g>
<text x="${cx}" y="${cy}" text-anchor="middle" dominant-baseline="central"
font-family="monospace" font-weight="bold" font-size="${fontSize}" fill="${textColor}">${this.escapeXml(marking)}</text>
</g>
${callouts}
${valueCaption}
</svg>`;
if (options.specEl) {
options.specEl.textContent =
`${pkgKey} (${pkg.metric}) · ${this.formatMm(pkg.l)} × ${this.formatMm(pkg.w)} · ${this.formatHenries(henries)}`;
}
}
/**
* Draws a diode. LEDs become a coloured 5 mm dome (long lead = anode, short lead + flat =
* cathode); every other kind (rectifier / Zener / Schottky / TVS) becomes an axial body with a
* cathode band. The kind only changes colour/caption — diode markings aren't standardised.
*/
drawDiode(target, subtype, voltage, options = {}) {
if (subtype === "led") {
this.drawLed(target, this.safeColor(options.bodyColor || options.color, "#c0392b"));
return;
}
this.drawAxialDiode(target, subtype, voltage, options);
}
/**
* Axial diode: a dark glass/epoxy body with a light cathode band near one end and two leads.
* A recognised part marking (e.g. "1N4001") is printed lengthwise on the body itself, like a
* real diode — otherwise a small caption below the leads shows the voltage or the diode kind.
*/
drawAxialDiode(target, subtype, voltage, options = {}) {
const uid = this.svgId();
const margin = 6;
const leadExt = 74;
const bodyW = 150;
const bodyH = 64;
const bodyX = margin + leadExt;
const width = bodyW + 2 * (margin + leadExt);
const cy = 58;
const bodyY = cy - bodyH / 2;
const bodyBottom = bodyY + bodyH;
const body = this.safeColor(options.bodyColor, "#20242a");
//Cathode band (the stripe marking the "line" side of the diode symbol), near the right end.
const bandW = 15;
const bandX = bodyX + bodyW - 34;
const marking = options.marking || null;
const hasVoltage = voltage && voltage > 0;
const labels = {diode: "Diode", zener: "Zener", schottky: "Schottky", tvs: "TVS"};
//Below the body we show: the voltage (if known — e.g. Zener/TVS), else — when there's no
//part-number marking on the body — the diode kind. So voltage is shown whenever we have it.
const caption = hasVoltage ? `${this.trimNumber(voltage)} V` : (marking ? "" : (labels[subtype] || "Diode"));
//Compact canvas only when the body carries a marking AND there's no caption to fit below.
const height = (marking && caption === "") ? bodyBottom + bodyY : bodyBottom + 30;
const textColor = this.contrastColor(body);
const markingFontSize = marking
? Math.max(11, Math.min(18, Math.round((bodyW - 8) * 1.7 / Math.max(4, marking.length))))
: 0;
const bodyMarking = marking
? `<text x="${bodyX + bodyW / 2}" y="${cy}" text-anchor="middle" dominant-baseline="central"
font-family="monospace" font-weight="700" font-size="${markingFontSize}" fill="${textColor}"
style="paint-order:stroke" stroke="${body}" stroke-width="0.5">${this.escapeXml(marking)}</text>`
: "";
const belowCaption = caption === ""
? ""
: `<text x="${width / 2}" y="${height - 10}" text-anchor="middle" font-family="monospace" font-size="15" font-weight="700" fill="#3a4149">${caption}</text>`;
target.innerHTML = `
<svg viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg" style="max-width: 460px; width: 100%; height: auto;">
<defs>
${this.leadGradient(uid)}
${this.cylinderGradient(uid)}
${this.endVignetteGradient(uid)}
${this.blurFilter(uid)}
<clipPath id="${uid}clip"><rect x="${bodyX}" y="${bodyY}" width="${bodyW}" height="${bodyH}" rx="16" ry="16"/></clipPath>
${this.shadowFilter(uid)}
</defs>
<g>
<rect x="${margin}" y="${cy - 5}" width="${width - 2 * margin}" height="10" rx="5" fill="url(#${uid}lead)"/>
<rect x="${bodyX}" y="${bodyY}" width="${bodyW}" height="${bodyH}" rx="16" ry="16" fill="${body}" stroke="#00000066" stroke-width="1" filter="url(#${uid}shadow)"/>
<g clip-path="url(#${uid}clip)">
<rect x="${bandX}" y="${bodyY}" width="${bandW}" height="${bodyH}" fill="#e6e9ee"/>
<rect x="${bodyX}" y="${bodyY}" width="${bodyW}" height="${bodyH}" fill="url(#${uid}cyl)"/>
<rect x="${bodyX}" y="${bodyY}" width="${bodyW}" height="${bodyH}" fill="url(#${uid}vig)"/>
<ellipse cx="${bodyX + bodyW * 0.42}" cy="${bodyY + bodyH * 0.26}" rx="${bodyW * 0.34}" ry="4" fill="#ffffff" opacity="0.4" filter="url(#${uid}blur)"/>
${bodyMarking}
</g>
<rect x="${bodyX}" y="${bodyY}" width="${bodyW}" height="${bodyH}" rx="16" ry="16" fill="none" stroke="#00000055" stroke-width="1"/>
</g>
${belowCaption}
</svg>`;
}
/** 5 mm through-hole LED: a coloured epoxy dome with a reflector cup, specular highlight and legs. */
drawLed(target, color) {
const uid = this.svgId();
const width = 200;
const height = 232;
const cx = 100;
const domeR = 50;
const domeTopY = 26;
const sidesTopY = domeTopY + domeR;
const rimY = 150;
const left = cx - domeR;
const right = cx + domeR;
const flangeTop = rimY;
const flangeH = 16;
const flangeBottom = rimY + flangeH;
const flangeL = 44;
const flangeR = 156;
const anodeX = cx - 18;
const cathodeX = cx + 18;
const leadTop = flangeBottom - 2;
const anodeBottom = height - 30;
const cathodeBottom = height - 50;
//Rounded-top body: straight sides up to a hemisphere.
const bodyPath = `M ${left} ${rimY} L ${left} ${sidesTopY} A ${domeR} ${domeR} 0 0 1 ${right} ${sidesTopY} L ${right} ${rimY} Z`;
//Flange: rounded on the anode (left) side, flat on the cathode (right) side.
const flangePath = `M ${flangeL + 6} ${flangeTop} L ${flangeR} ${flangeTop} L ${flangeR} ${flangeBottom} L ${flangeL + 6} ${flangeBottom} Q ${flangeL} ${flangeBottom} ${flangeL} ${flangeBottom - 6} L ${flangeL} ${flangeTop + 6} Q ${flangeL} ${flangeTop} ${flangeL + 6} ${flangeTop} Z`;
target.innerHTML = `
<svg viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg" style="max-width: 260px; width: 100%; height: auto;">
<defs>
${this.leadGradient(uid)}
<radialGradient id="${uid}led" cx="0.4" cy="0.3" r="0.8">
<stop offset="0" stop-color="#ffffff" stop-opacity="0.85"/>
<stop offset="0.4" stop-color="${color}" stop-opacity="0.92"/>
<stop offset="1" stop-color="${color}"/>
</radialGradient>
${this.blurFilter(uid)}
${this.shadowFilter(uid)}
</defs>
<g>
<rect x="${anodeX - 4}" y="${leadTop}" width="8" height="${anodeBottom - leadTop}" rx="3" fill="url(#${uid}lead)"/>
<rect x="${cathodeX - 4}" y="${leadTop}" width="8" height="${cathodeBottom - leadTop}" rx="3" fill="url(#${uid}lead)"/>
<path d="${flangePath}" fill="${color}" stroke="#00000055" stroke-width="1" filter="url(#${uid}shadow)"/>
<path d="${flangePath}" fill="#000000" opacity="0.20"/>
<path d="${bodyPath}" fill="url(#${uid}led)" stroke="#00000040" stroke-width="1"/>
<path d="M ${cx - 16} ${rimY - 8} L ${cx + 16} ${rimY - 8} L ${cx + 9} ${rimY - 32} L ${cx - 9} ${rimY - 32} Z" fill="#000000" opacity="0.28"/>
<rect x="${cx - 4}" y="${rimY - 25}" width="8" height="7" rx="1.5" fill="#fff6c0"/>
<ellipse cx="${cx - 15}" cy="${domeTopY + 34}" rx="11" ry="24" fill="#ffffff" opacity="0.55" filter="url(#${uid}blur)"/>
</g>
<text x="${cx}" y="${height - 8}" text-anchor="middle" font-family="monospace" font-size="15" font-weight="700" fill="#3a4149">LED</text>
</svg>`;
}
resistorPowerValue() {
const v = this.hasResistorPowerTarget ? this.resistorPowerTarget.value : "0.25";
return RESISTOR_POWERS[v] ? v : "0.25";
}
resistorLeadValue() {
const v = (this.hasResistorLeadTarget ? this.resistorLeadTarget.value : null) || this.resistorLead || "medium";
return Object.prototype.hasOwnProperty.call(RESISTOR_LEAD_LENGTHS, v) ? v : "medium";
}
/*
* ---------------------------------------------------------------
* Capacitor code
* ---------------------------------------------------------------
*/
/** Recomputes the linked value/code fields (and the picture) from whichever was edited. */
syncCap(event) {
const field = event.currentTarget.dataset.field;
const raw = event.currentTarget.value;
let pf = null;
if (field === "value") {
const farads = this.parseValue(raw, "F");
pf = farads === null ? null : farads * 1e12;
} else {
// Split off an optional trailing tolerance letter (e.g. the K in 104K) and,
// if it is a known code, reflect it in the tolerance selector.
const up = raw.trim().toUpperCase();
const letterMatch = up.match(/^([0-9R]+)([A-Z])$/);
const body = letterMatch ? letterMatch[1] : up;
if (letterMatch && this.hasCapToleranceTarget && CAP_TOLERANCE[letterMatch[2]]) {
this.capToleranceTarget.value = letterMatch[2];
}
pf = this.capCodeToPf(body);
}
if (pf === null || !(pf > 0)) {
event.currentTarget.classList.add("is-invalid");
this.capPf = null;
if (this.hasCapResultTarget) {
this.capResultTarget.textContent = raw.trim() === "" ? "" : trans("tools.value_calc.invalid_input");
}
this.capSvgTarget.innerHTML = "";
return;
}
event.currentTarget.classList.remove("is-invalid");
this.capPf = pf;
this.setCapFields(pf, field);
this.redrawCap();
}
/** Writes the value/code fields from a capacitance in pF (skips the field being edited). */
setCapFields(pf, except) {
if (except !== "value" && this.hasCapValueInputTarget) {
this.capValueInputTarget.value = this.formatFarads(pf);
this.capValueInputTarget.classList.remove("is-invalid");
}
if (except !== "code" && this.hasCapCodeInputTarget) {
const code = this.pfToCapCode(pf);
this.capCodeInputTarget.value = code ?? "";
this.capCodeInputTarget.classList.remove("is-invalid");
}
}
/** Draws the capacitor picture (the printed code) plus the value/spec/tolerance text. */
redrawCap() {
if (this.capPf === null || this.capPf === undefined || !(this.capPf > 0)) {
return;
}
const code = this.pfToCapCode(this.capPf);
const letter = this.hasCapToleranceTarget ? this.capToleranceTarget.value : "";
let text = `${this.formatFarads(this.capPf)} (${this.formatFarads(this.capPf, true)})`;
const tol = this.capToleranceText();
if (tol !== "") {
text += ` · ${trans("tools.value_calc.tolerance")}: ${tol}`;
}
if (this.hasCapResultTarget) {
this.capResultTarget.textContent = text;
}
//Real caps print the tolerance letter right after the code (e.g. "104K").
const marking = code ? (letter ? code + letter : code) : this.formatFarads(this.capPf);
this.drawCapacitor(this.capSvgTarget, marking);
}
/** Maps a tolerance percentage (or small-cap pF value) to its capacitor letter code. */
capToleranceLetterForPercent(p) {
const map = {0.1: "B", 0.25: "C", 0.5: "D", 1: "F", 2: "G", 5: "J", 10: "K", 20: "M"};
return map[p] || "";
}
/** Human-readable tolerance for the selected capacitor tolerance letter, or "". */
capToleranceText() {
const letter = this.hasCapToleranceTarget ? this.capToleranceTarget.value : "";
return letter && CAP_TOLERANCE[letter] ? CAP_TOLERANCE[letter] : "";
}
/**
* Converts a printed ceramic/film capacitor code into picofarads.
* Supports R-notation (4R7 = 4.7 pF), plain 1-2 digit values (47 = 47 pF)
* and the 3-digit EIA code (104 = 100 nF, with 8/9 as ×0.01/×0.1).
* Returns null when the code can't be parsed.
*/
capCodeToPf(code) {
if (/^\d*R\d*$/.test(code) && code.includes("R")) {
// R-notation, e.g. 4R7 = 4.7 pF, R47 = 0.47 pF
const val = parseFloat(code.replace("R", "."));
return Number.isNaN(val) ? null : val;
}
if (/^\d{1,2}$/.test(code)) {
// Plain value directly in pF (typical for caps below 100 pF)
return parseInt(code, 10);
}
if (/^\d{3}$/.test(code)) {
const significant = parseInt(code.substring(0, 2), 10);
const mult = parseInt(code.charAt(2), 10);
if (mult === 8) {
return significant * 0.01;
}
if (mult === 9) {
return significant * 0.1;
}
return significant * Math.pow(10, mult);
}
return null;
}
/**
* Returns the marking that is typically printed on a ceramic capacitor for
* the given value in picofarads: R-notation below 10 pF, the plain value
* for 10-99 pF, and the 3-digit EIA code from 100 pF upwards.
*/
pfToCapCode(pf) {
if (pf < 10) {
// R-notation, e.g. 4.7 -> 4R7, 0.47 -> R47
const s = parseFloat(pf.toFixed(2)).toString();
if (Number.isInteger(pf)) {
return s;
}
return s.startsWith("0.") ? "R" + s.slice(2) : s.replace(".", "R");
}
// Plain value only fits 10-99 pF; values that round up to 100 must use the EIA code below (100 pF -> "101").
if (Math.round(pf) < 100) {
return Math.round(pf).toString();
}
// Two significant figures + power-of-ten multiplier digit
let exp = Math.floor(Math.log10(pf)) - 1;
let significant = Math.round(pf / Math.pow(10, exp));
if (significant >= 100) {
significant = Math.round(significant / 10);
exp += 1;
}
if (exp < 0 || exp > 7) {
return null;
}
return significant.toString().padStart(2, "0") + exp.toString();
}
/** Draws a ceramic capacitor (radial disc or dipped MLCC blob) with marking and callouts. */
drawCapacitor(target, marking) {
const uid = this.svgId();
const shape = this.capShapeValue();
const diam = this.capDiameterValue();
const pitch = this.capPitchValue();
const pitchIn = CAP_PITCHES[pitch];
const voltage = this.capVoltageValue();
const fill = this.safeColor(this.bodyColor(this.hasCapBodyColorTarget ? this.capBodyColorTarget : null, "#e0a63a"), "#e0a63a");
const textColor = this.contrastColor(fill);
const shadow = textColor === "#f5f5f5" ? "#00000088" : "#ffffff66";
const W = 230;
const cx = W / 2;
const pxPerMm = 7;
const topMargin = 34;
// The body grows with the chosen diameter, within sensible visual bounds.
const r = Math.max(42, Math.min(96, 52 + (diam - 5) * 4));
const cy = topMargin + r;
const pitchPx = Math.max(16, parseFloat(pitch) * pxPerMm);
const leadX1 = cx - pitchPx / 2;
const leadX2 = cx + pitchPx / 2;
const leadExtra = CAP_LEAD_LENGTHS[this.capLeadValue()] ?? CAP_LEAD_LENGTHS.medium;
// Body outline + highlight geometry for the chosen shape.
let bodyPath, bodyBottom, gloss, spec, botShadow, topDip, textCy;
if (shape === "blob") {
// Multilayer (MLCC) style: a tall, dipped rounded body.
const bw = r * 1.5;
const bh = r * 1.95;
const bx = cx - bw / 2;
const by = topMargin;
const k = bw * 0.44;
bodyBottom = by + bh;
bodyPath =
`M ${bx} ${by + k} Q ${bx} ${by} ${bx + k} ${by} L ${bx + bw - k} ${by} ` +
`Q ${bx + bw} ${by} ${bx + bw} ${by + k} L ${bx + bw} ${bodyBottom - k} ` +
`Q ${bx + bw} ${bodyBottom} ${bx + bw - k} ${bodyBottom} L ${bx + k} ${bodyBottom} ` +
`Q ${bx} ${bodyBottom} ${bx} ${bodyBottom - k} Z`;
gloss = {cx, cy: by + bh * 0.26, rx: bw * 0.4, ry: bh * 0.22};
spec = {cx: cx - bw * 0.22, cy: by + bh * 0.16, rx: 13, ry: 7};
botShadow = {cx, cy: bodyBottom - bh * 0.1, rx: bw * 0.42, ry: bh * 0.12};
topDip = {cx, cy: by + 3, rx: bw * 0.18, ry: 7};
textCy = by + bh * 0.42;
} else {
// Radial disc: a near-full circle whose bottom tapers *inward* to the two lead exits,
// with a small dip between the leads. The shoulder is always kept wider than the lead
// roots so the taper never bulges out past the circle.
const shoulderHalf = Math.min(r - 3, Math.max(0.6 * r, pitchPx / 2 + 14));
const shoulderY = cy + Math.sqrt(Math.max(0, r * r - shoulderHalf * shoulderHalf));
const rt = {x: cx + shoulderHalf, y: shoulderY};
const lt = {x: cx - shoulderHalf, y: shoulderY};
const rRoot = Math.min(leadX2 + 4, rt.x - 2);
const lRoot = Math.max(leadX1 - 4, lt.x + 2);
bodyBottom = cy + r + Math.max(6, r * 0.1);
const notchY = bodyBottom - Math.max(7, r * 0.13);
const drop = bodyBottom - shoulderY;
bodyPath =
`M ${lt.x} ${lt.y} ` +
`A ${r} ${r} 0 1 1 ${rt.x} ${rt.y} ` +
`C ${rt.x} ${shoulderY + drop * 0.5} ${rRoot + 4} ${bodyBottom - drop * 0.28} ${rRoot} ${bodyBottom} ` +
`Q ${cx + (rRoot - cx) * 0.5} ${bodyBottom} ${cx} ${notchY} ` +
`Q ${cx - (rRoot - cx) * 0.5} ${bodyBottom} ${lRoot} ${bodyBottom} ` +
`C ${lRoot - 4} ${bodyBottom - drop * 0.28} ${lt.x} ${shoulderY + drop * 0.5} ${lt.x} ${lt.y} Z`;
gloss = {cx, cy: cy - r * 0.28, rx: r * 0.72, ry: r * 0.34};
spec = {cx: cx - r * 0.26, cy: cy - r * 0.44, rx: r * 0.16, ry: r * 0.09};
botShadow = {cx, cy: bodyBottom - r * 0.14, rx: r * 0.6, ry: r * 0.2};
topDip = {cx, cy: cy - r + 5, rx: r * 0.14, ry: 6};
textCy = cy - r * 0.04;
}
// Marking text (capacitance code) with an optional printed voltage line below it.
const baseFont = Math.round(Math.max(20, Math.min(40, r * 0.52)));
const codeFont = marking.length > 4 ? Math.round(baseFont * 0.8) : baseFont;
const codeY = voltage ? textCy - codeFont * 0.42 : textCy;
const voltFont = Math.round(codeFont * 0.55);
const voltageSvg = voltage
? `<text x="${cx}" y="${textCy + codeFont * 0.55}" text-anchor="middle" dominant-baseline="central"
font-family="monospace" font-weight="bold" font-size="${voltFont}" fill="${textColor}"
style="paint-order:stroke" stroke="${shadow}" stroke-width="0.5">${voltage}V</text>`
: "";
// Leads.
const leadTop = bodyBottom - 6;
const leadEnd = bodyBottom + leadExtra;
const leadW = 3.4;
const leads =
`<rect x="${leadX1 - leadW / 2}" y="${leadTop}" width="${leadW}" height="${leadEnd - leadTop}" rx="${leadW / 2}" fill="url(#${uid}lead)"/>`
+ `<rect x="${leadX2 - leadW / 2}" y="${leadTop}" width="${leadW}" height="${leadEnd - leadTop}" rx="${leadW / 2}" fill="url(#${uid}lead)"/>`;
const H = Math.ceil(leadEnd + 22);
const pitchLabel = pitchIn ? `pitch ${this.formatMm(parseFloat(pitch))} (${pitchIn})` : `pitch ${this.formatMm(parseFloat(pitch))}`;
const callouts =
this.dimH(cx - r, cx + r, topMargin - 12, `${this.formatMm(diam)}`)
+ this.dimH(leadX1, leadX2, H - 10, pitchLabel);
//The body shows the printed code (and voltage); add the decoded capacitance + tolerance below.
const capTol = this.capToleranceText();
const valueLabel = this.capPf > 0 ? this.formatFarads(this.capPf) + (capTol ? ` · ${capTol}` : "") : "";
const totalH = H + (valueLabel ? 24 : 0);
const valueCaption = valueLabel
? `<text x="${cx}" y="${totalH - 8}" text-anchor="middle" font-family="monospace" font-size="15" font-weight="700" fill="#3a4149">${valueLabel}</text>`
: "";
const svg = `
<svg viewBox="0 0 ${W} ${totalH}" xmlns="http://www.w3.org/2000/svg" style="max-width: 250px; width: 100%; height: auto;">
<defs>
${this.leadGradient(uid)}
${this.blurFilter(uid)}
<clipPath id="${uid}clip"><path d="${bodyPath}"/></clipPath>
<linearGradient id="${uid}amb" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffffff" stop-opacity="0.2"/>
<stop offset="0.42" stop-color="#ffffff" stop-opacity="0"/>
<stop offset="1" stop-color="#000000" stop-opacity="0.16"/>
</linearGradient>
</defs>
<g>
${leads}
<path d="${bodyPath}" fill="${fill}"/>
<g clip-path="url(#${uid}clip)">
<rect x="0" y="0" width="${W}" height="${H}" fill="url(#${uid}amb)"/>
<ellipse cx="${gloss.cx}" cy="${gloss.cy}" rx="${gloss.rx}" ry="${gloss.ry}" fill="#ffffff" opacity="0.26" filter="url(#${uid}blur)"/>
<ellipse cx="${spec.cx}" cy="${spec.cy}" rx="${spec.rx}" ry="${spec.ry}" fill="#ffffff" opacity="0.6" filter="url(#${uid}blur)"/>
<ellipse cx="${botShadow.cx}" cy="${botShadow.cy}" rx="${botShadow.rx}" ry="${botShadow.ry}" fill="#000000" opacity="0.12" filter="url(#${uid}blur)"/>
<ellipse cx="${topDip.cx}" cy="${topDip.cy}" rx="${topDip.rx}" ry="${topDip.ry}" fill="#000000" opacity="0.18" filter="url(#${uid}blur)"/>
</g>
<path d="${bodyPath}" fill="none" stroke="#00000055" stroke-width="1.2"/>
<text x="${cx}" y="${codeY}" text-anchor="middle" dominant-baseline="central"
font-family="monospace" font-weight="bold" font-size="${codeFont}"
fill="${textColor}" style="paint-order:stroke" stroke="${shadow}" stroke-width="0.6">${this.escapeXml(marking)}</text>
${voltageSvg}
</g>
${callouts}
${valueCaption}
</svg>`;
target.innerHTML = svg;
this.updateCapSpec();
}
capShapeValue() {
const v = this.hasCapShapeTarget ? this.capShapeTarget.value : "disc";
return v === "blob" ? "blob" : "disc";
}
capLeadValue() {
const v = this.hasCapLeadTarget ? this.capLeadTarget.value : "medium";
return Object.prototype.hasOwnProperty.call(CAP_LEAD_LENGTHS, v) ? v : "medium";
}
capPitchValue() {
const v = this.hasCapPitchTarget ? this.capPitchTarget.value : "5.08";
return CAP_PITCHES[v] !== undefined ? v : "5.08";
}
capDiameterValue() {
const v = this.hasCapDiameterTarget ? parseFloat(this.capDiameterTarget.value) : NaN;
return Number.isFinite(v) && v > 0 ? v : 5;
}
capVoltageValue() {
const v = this.hasCapVoltageTarget ? this.capVoltageTarget.value.trim() : "";
return /^\d+(\.\d+)?$/.test(v) ? v : "";
}
updateCapSpec() {
if (!this.hasCapSpecTarget) {
return;
}
const pitch = this.capPitchValue();
const pitchIn = CAP_PITCHES[pitch];
let spec = `${this.formatMm(this.capDiameterValue())} · pitch ${this.formatMm(parseFloat(pitch))}${pitchIn ? ` (${pitchIn})` : ""}`;
const voltage = this.capVoltageValue();
if (voltage) {
spec += ` · ${voltage} V`;
}
this.capSpecTarget.textContent = spec;
}
/** Re-renders the capacitor picture when the body color changes. */
updateCapacitorColor() {
this.redrawCap();
}
/** Updates the spec line and re-renders the capacitor picture. */
updateCapDimensions() {
this.updateCapSpec();
this.redrawCap();
}
applyCapBodyColor(event) {
if (this.hasCapBodyColorTarget) {
this.capBodyColorTarget.value = event.currentTarget.dataset.color;
}
this.redrawCap();
}
/** Unique id prefix per drawn SVG, so gradient/filter ids never collide. */
svgId() {
this.svgSeq = (this.svgSeq || 0) + 1;
return `vc${this.svgSeq}_`;
}
/** Vertical metallic gradient used for component leads. */
leadGradient(uid) {
return `<linearGradient id="${uid}lead" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#9aa0a6"/>
<stop offset="0.45" stop-color="#f4f6f8"/>
<stop offset="0.55" stop-color="#e7eaed"/>
<stop offset="1" stop-color="#6f747a"/>
</linearGradient>`;
}
/** Vertical metallic gradient for SMD terminations. */
metalGradient(uid) {
return `<linearGradient id="${uid}metal" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#eef1f4"/>
<stop offset="0.5" stop-color="#c2c7cd"/>
<stop offset="1" stop-color="#9098a0"/>
</linearGradient>`;
}
/** Top-light / bottom-dark overlay that turns a flat shape into a cylinder. */
cylinderGradient(uid) {
return `<linearGradient id="${uid}cyl" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffffff" stop-opacity="0.55"/>
<stop offset="0.16" stop-color="#ffffff" stop-opacity="0.16"/>
<stop offset="0.46" stop-color="#ffffff" stop-opacity="0"/>
<stop offset="0.72" stop-color="#000000" stop-opacity="0.16"/>
<stop offset="1" stop-color="#000000" stop-opacity="0.42"/>
</linearGradient>`;
}
/** Softer top-gloss overlay for caps and SMD bodies. */
glossGradient(uid) {
return `<linearGradient id="${uid}gloss" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffffff" stop-opacity="0.4"/>
<stop offset="0.4" stop-color="#ffffff" stop-opacity="0.05"/>
<stop offset="0.62" stop-color="#000000" stop-opacity="0"/>
<stop offset="1" stop-color="#000000" stop-opacity="0.32"/>
</linearGradient>`;
}
/** Radial highlight used as a specular reflection on the cap body. */
specularGradient(uid) {
return `<radialGradient id="${uid}spec" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="#ffffff" stop-opacity="0.55"/>
<stop offset="1" stop-color="#ffffff" stop-opacity="0"/>
</radialGradient>`;
}
/** Horizontal vignette that darkens the rounded ends of a cylinder. */
endVignetteGradient(uid) {
return `<linearGradient id="${uid}vig" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#000000" stop-opacity="0.38"/>
<stop offset="0.1" stop-color="#000000" stop-opacity="0.06"/>
<stop offset="0.16" stop-color="#000000" stop-opacity="0"/>
<stop offset="0.84" stop-color="#000000" stop-opacity="0"/>
<stop offset="0.9" stop-color="#000000" stop-opacity="0.06"/>
<stop offset="1" stop-color="#000000" stop-opacity="0.38"/>
</linearGradient>`;
}
/** Soft gaussian blur, used for specular streaks and ground shadows. */
blurFilter(uid) {
return `<filter id="${uid}blur" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="3"/>
</filter>`;
}
/** Soft, slightly offset drop shadow filter. */
/**
* A soft, slightly offset drop shadow, built from primitives that survive the server-side SVG
* sanitizer applied when the picture is attached to a part. The shorthand <feDropShadow> element
* is NOT on the sanitizer's filter-primitive allow-list and gets stripped on save, leaving an
* empty <filter> — which the SVG spec defines as fully transparent, silently hiding whatever
* element referenced it (only visible once the attachment is viewed as a real, saved image
* rather than in this live preview). This is the equivalent built from feGaussianBlur/feOffset/
* feFlood/feComposite/feMerge, all of which are allow-listed and pass through unchanged.
*/
shadowFilter(uid) {
return `<filter id="${uid}shadow" x="-15%" y="-20%" width="130%" height="160%">
<feGaussianBlur in="SourceAlpha" stdDeviation="5" result="${uid}blurShadow"/>
<feOffset in="${uid}blurShadow" dx="0" dy="4" result="${uid}offsetShadow"/>
<feFlood flood-color="#000000" flood-opacity="0.28" result="${uid}floodShadow"/>
<feComposite in="${uid}floodShadow" in2="${uid}offsetShadow" operator="in" result="${uid}coloredShadow"/>
<feMerge>
<feMergeNode in="${uid}coloredShadow"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>`;
}
/** Horizontal dimension line with end ticks, arrows and a centered label above. */
dimH(x1, x2, y, label) {
const t = 4;
return `<g stroke="${DIM_COLOR}" stroke-width="1" fill="${DIM_COLOR}" font-size="11" font-family="system-ui, Arial, sans-serif">
<line x1="${x1}" y1="${y - t}" x2="${x1}" y2="${y + t}"/>
<line x1="${x2}" y1="${y - t}" x2="${x2}" y2="${y + t}"/>
<line x1="${x1}" y1="${y}" x2="${x2}" y2="${y}"/>
<polygon stroke="none" points="${x1},${y} ${x1 + 6},${y - 3} ${x1 + 6},${y + 3}"/>
<polygon stroke="none" points="${x2},${y} ${x2 - 6},${y - 3} ${x2 - 6},${y + 3}"/>
<text x="${(x1 + x2) / 2}" y="${y - 5}" text-anchor="middle" stroke="none">${label}</text>
</g>`;
}
/** Vertical dimension line (label centered above) with optional extension lines. */
dimV(y1, y2, x, label, extFromX = null) {
const t = 4;
const ext = extFromX === null ? "" :
`<line x1="${extFromX}" y1="${y1}" x2="${x + t}" y2="${y1}" stroke-dasharray="2 2"/>
<line x1="${extFromX}" y1="${y2}" x2="${x + t}" y2="${y2}" stroke-dasharray="2 2"/>`;
return `<g stroke="${DIM_COLOR}" stroke-width="1" fill="${DIM_COLOR}" font-size="11" font-family="system-ui, Arial, sans-serif">
${ext}
<line x1="${x - t}" y1="${y1}" x2="${x + t}" y2="${y1}"/>
<line x1="${x - t}" y1="${y2}" x2="${x + t}" y2="${y2}"/>
<line x1="${x}" y1="${y1}" x2="${x}" y2="${y2}"/>
<polygon stroke="none" points="${x},${y1} ${x - 3},${y1 + 6} ${x + 3},${y1 + 6}"/>
<polygon stroke="none" points="${x},${y2} ${x - 3},${y2 - 6} ${x + 3},${y2 - 6}"/>
<text x="${x}" y="${y1 - 6}" text-anchor="middle" stroke="none">${label}</text>
</g>`;
}
/** Formats a millimeter value without trailing zeros. */
formatMm(mm) {
return `${this.trimNumber(mm)} mm`;
}
/** Formats a power rating in watts, preferring the fractional label. */
formatPower(watts) {
const fractions = {0.125: "1/8 W", 0.25: "1/4 W", 0.33: "1/3 W", 0.5: "1/2 W"};
return fractions[watts] ?? `${this.trimNumber(watts)} W`;
}
/** Returns the value of a color input, falling back to a default. */
bodyColor(target, fallback) {
return target && target.value ? target.value : fallback;
}
/** Picks black or white text for readable contrast on the given hex color. */
contrastColor(hex) {
const c = hex.replace("#", "");
if (c.length < 6) {
return "#1a1100";
}
const r = parseInt(c.substring(0, 2), 16);
const g = parseInt(c.substring(2, 4), 16);
const b = parseInt(c.substring(4, 6), 16);
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.6 ? "#1a1100" : "#f5f5f5";
}
/**
* Escapes a string for safe interpolation into the SVG markup we build with template strings and
* assign via innerHTML. The live preview is NOT server-sanitized, so anything derived from part
* data (e.g. a diode marking) must be escaped here as defence-in-depth against markup injection.
*/
escapeXml(value) {
return String(value).replace(/[&<>"']/g, (c) =>
({"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"})[c]);
}
/** Returns hex only if it is a valid #rgb/#rrggbb(aa) colour, else the fallback — so a colour value can't break out of an attribute. */
safeColor(hex, fallback = "#000000") {
return /^#[0-9a-fA-F]{3,8}$/.test(String(hex)) ? String(hex) : fallback;
}
/*
* ---------------------------------------------------------------
* SMD resistor code
* ---------------------------------------------------------------
*/
/** Recomputes all linked SMD fields (and the picture) from whichever was edited. */
syncSmd(event) {
const field = event.currentTarget.dataset.field;
const raw = event.currentTarget.value;
const ohms = field === "value" ? this.parseValue(raw, "R") : this.smdCodeToOhms(raw);
if (ohms === null || !(ohms > 0)) {
event.currentTarget.classList.add("is-invalid");
this.smdOhms = null;
if (this.hasSmdResultTarget) {
this.smdResultTarget.textContent = raw.trim() === "" ? "" : trans("tools.value_calc.invalid_input");
}
this.smdSvgTarget.innerHTML = "";
return;
}
event.currentTarget.classList.remove("is-invalid");
this.smdOhms = ohms;
this.setSmdFields(ohms, field);
this.redrawSmd();
}
/** Fills the value / 3-digit / 4-digit / EIA-96 fields (skips the field being edited). */
setSmdFields(ohms, except) {
const fields = {
value: () => this.formatOhms(ohms),
code3: () => this.ohmsToSmdCode(ohms) ?? "",
code4: () => this.ohmsTo4Digit(ohms) ?? "",
eia96: () => this.ohmsToEia96(ohms) ?? "—",
};
const targets = {
value: this.hasSmdValueInputTarget ? this.smdValueInputTarget : null,
code3: this.hasSmdCode3Target ? this.smdCode3Target : null,
code4: this.hasSmdCode4Target ? this.smdCode4Target : null,
eia96: this.hasSmdEia96Target ? this.smdEia96Target : null,
};
for (const key of Object.keys(fields)) {
const t = targets[key];
if (!t) {
continue;
}
if (key !== except) {
t.value = fields[key]();
}
t.classList.remove("is-invalid");
}
}
/** Draws the SMD chip using the marking currently selected as "printed on the part". */
redrawSmd() {
if (this.smdOhms === null || this.smdOhms === undefined || !(this.smdOhms > 0)) {
return;
}
const codes = {
code3: this.ohmsToSmdCode(this.smdOhms),
code4: this.ohmsTo4Digit(this.smdOhms),
eia96: this.ohmsToEia96(this.smdOhms),
};
const mark = this.smdMarking || "code3";
const marking = codes[mark] || codes.code3 || this.formatOhms(this.smdOhms);
if (this.hasSmdResultTarget) {
this.smdResultTarget.textContent = this.formatOhms(this.smdOhms);
}
this.drawSmd(this.smdSvgTarget, marking, {tolerance: this.smdTolerance, voltage: this.smdVoltage});
this.highlightSmdMarking();
}
/** Chooses which code is printed on the drawn chip. */
pickSmdMarking(event) {
this.smdMarking = event.currentTarget.dataset.mark;
this.redrawSmd();
}
/** Outlines the field whose code is currently drawn on the chip. */
highlightSmdMarking() {
const map = {
code3: this.hasSmdCode3Target ? this.smdCode3Target : null,
code4: this.hasSmdCode4Target ? this.smdCode4Target : null,
eia96: this.hasSmdEia96Target ? this.smdEia96Target : null,
};
const active = this.smdMarking || "code3";
for (const [key, t] of Object.entries(map)) {
if (t) {
t.classList.toggle("border-primary", key === active);
t.classList.toggle("border-2", key === active);
}
}
}
/** Parses any SMD marking (R-notation, EIA-96, 3-digit, 4-digit) to ohms, or null. */
smdCodeToOhms(raw) {
const code = (raw || "").trim().toUpperCase();
if (code === "") {
return null;
}
if (code.includes("R") && /^\d*R\d*$/.test(code)) {
const v = parseFloat(code.replace("R", "."));
return Number.isNaN(v) ? null : v;
}
if (/^\d{2}[A-Z]$/.test(code)) {
const n = parseInt(code.substring(0, 2), 10);
const letter = code.charAt(2);
if (n >= 1 && n <= 96 && EIA96_MULTIPLIERS[letter] !== undefined) {
return EIA96_VALUES[n - 1] * EIA96_MULTIPLIERS[letter];
}
return null;
}
if (/^\d{3}$/.test(code)) {
return parseInt(code.substring(0, 2), 10) * Math.pow(10, parseInt(code.charAt(2), 10));
}
if (/^\d{4}$/.test(code)) {
return parseInt(code.substring(0, 3), 10) * Math.pow(10, parseInt(code.charAt(3), 10));
}
return null;
}
/** ohms -> 4-digit precision code (3 significant figures), R-notation below 100 Ω. */
ohmsTo4Digit(ohms) {
if (!(ohms > 0)) {
return null;
}
if (ohms < 100) {
let s = parseFloat(ohms.toPrecision(3)).toString();
if (!s.includes(".")) {
s += ".0";
}
return s.startsWith("0.") ? "R" + s.slice(2) : s.replace(".", "R");
}
let exp = Math.floor(Math.log10(ohms)) - 2;
let significant = Math.round(ohms / Math.pow(10, exp));
if (significant >= 1000) {
significant = Math.round(significant / 10);
exp += 1;
}
if (exp < 0 || exp > 9) {
return null;
}
return significant.toString().padStart(3, "0") + exp.toString();
}
/** ohms -> EIA-96 code (value code + multiplier letter) for E96 values, else null. */
ohmsToEia96(ohms) {
if (!(ohms > 0)) {
return null;
}
const order = ["A", "B", "C", "D", "E", "F", "X", "S", "Y", "R", "Z"];
for (const letter of order) {
const base = ohms / EIA96_MULTIPLIERS[letter];
const idx = EIA96_VALUES.findIndex((v) => Math.abs(v - base) < 0.5);
if (idx >= 0) {
return String(idx + 1).padStart(2, "0") + letter;
}
}
return null;
}
/** Re-renders the SMD chip when the package changes. */
updateSmd() {
this.redrawSmd();
}
/** Re-renders the SMD chip when the body color changes. */
updateSmdColor() {
this.redrawSmd();
}
applySmdBodyColor(event) {
if (this.hasSmdBodyColorTarget) {
this.smdBodyColorTarget.value = event.currentTarget.dataset.color;
}
this.redrawSmd();
}
/**
* Converts a resistance in ohms into the printed SMD marking: R-notation below
* 10 Ω (4.7 -> 4R7, 0.47 -> R47) and the 3-digit EIA code from 10 Ω upwards.
* Returns null when the value is out of the representable range.
*/
ohmsToSmdCode(ohms) {
if (!(ohms > 0)) {
return null;
}
if (ohms < 10) {
let s = parseFloat(ohms.toFixed(2)).toString();
if (!s.includes(".")) {
s += ".0";
}
return s.startsWith("0.") ? "R" + s.slice(2) : s.replace(".", "R");
}
// Two significant figures + power-of-ten multiplier digit
let exp = Math.floor(Math.log10(ohms)) - 1;
let significant = Math.round(ohms / Math.pow(10, exp));
if (significant >= 100) {
significant = Math.round(significant / 10);
exp += 1;
}
if (exp < 0 || exp > 7) {
return null;
}
return significant.toString().padStart(2, "0") + exp.toString();
}
/** Draws a 3D-shaded SMD chip resistor with marking and dimension callouts. */
drawSmd(target, marking, spec = {}) {
const uid = this.svgId();
const w = 300;
const pkgKey = this.smdPackageValue();
const pkg = SMD_PACKAGES[pkgKey];
// Body proportions follow the package: the length maps to a modest on-screen width
// (kept readable rather than true 1:1 scale) and the L:W ratio sets the height, so
// a 2512 looks noticeably larger than a 0402 and a 1210 looks squarer.
const bodyW = Math.round(120 + 90 * (pkg.l - 0.6) / (6.3 - 0.6));
const aspect = pkg.l / pkg.w;
const bodyH = Math.max(48, Math.min(122, Math.round(bodyW / aspect)));
const capW = Math.max(14, Math.round(bodyW * 0.13));
const cx = w / 2;
const bodyX = Math.round(cx - bodyW / 2);
const bodyY = Math.round(78 - bodyH / 2);
const bodyBottom = bodyY + bodyH;
const cy = bodyY + bodyH / 2;
const innerX = bodyX + capW;
const innerW = bodyW - 2 * capW;
// Fit the marking inside the ceramic window (bounded by both width and height).
const fontSize = Math.max(14, Math.min(
34,
Math.round(bodyH * 0.5),
Math.round(innerW * 1.6 / Math.max(3, marking.length))
));
const fill = this.safeColor(this.bodyColor(this.hasSmdBodyColorTarget ? this.smdBodyColorTarget : null, "#262626"), "#262626");
const textColor = this.contrastColor(fill);
const callouts =
this.dimH(bodyX, bodyX + bodyW, bodyBottom + 18, `L ${this.formatMm(pkg.l)}`)
+ this.dimV(bodyY, bodyBottom, bodyX + bodyW + 16, `W ${this.formatMm(pkg.w)}`, bodyX + bodyW);
//The chip itself shows the printed code; print the decoded value (+ tolerance) as a caption below.
const valueLabel = this.smdOhms > 0 ? this.formatOhms(this.smdOhms) + this.specSuffix(spec) : "";
const h = bodyBottom + 54;
const valueCaption = valueLabel
? `<text x="${cx}" y="${h - 12}" text-anchor="middle" font-family="monospace" font-size="16" font-weight="700" fill="#3a4149">${valueLabel}</text>`
: "";
const svg = `
<svg viewBox="0 0 ${w} ${h}" xmlns="http://www.w3.org/2000/svg" style="max-width: 300px; width: 100%; height: auto;">
<defs>
${this.metalGradient(uid)}
${this.glossGradient(uid)}
${this.blurFilter(uid)}
<clipPath id="${uid}clip"><rect x="${bodyX}" y="${bodyY}" width="${bodyW}" height="${bodyH}" rx="8" ry="8"/></clipPath>
${this.shadowFilter(uid)}
</defs>
<g>
<rect x="${bodyX}" y="${bodyY}" width="${bodyW}" height="${bodyH}" rx="8" ry="8" fill="url(#${uid}metal)" stroke="#00000055" stroke-width="1"/>
<g clip-path="url(#${uid}clip)">
<rect x="${innerX}" y="${bodyY}" width="${innerW}" height="${bodyH}" fill="${fill}"/>
<rect x="${innerX}" y="${bodyY}" width="${innerW}" height="${bodyH}" fill="url(#${uid}gloss)"/>
<rect x="${innerX}" y="${bodyY + 2}" width="${innerW}" height="2.5" fill="#ffffff" opacity="0.22"/>
<rect x="${innerX - 2}" y="${bodyY}" width="3" height="${bodyH}" fill="#000000" opacity="0.28"/>
<rect x="${innerX + innerW - 1}" y="${bodyY}" width="3" height="${bodyH}" fill="#000000" opacity="0.28"/>
</g>
<text x="${cx}" y="${cy}" text-anchor="middle" dominant-baseline="central"
font-family="monospace" font-weight="bold" font-size="${fontSize}" fill="${textColor}">${this.escapeXml(marking)}</text>
</g>
${callouts}
${valueCaption}
</svg>`;
target.innerHTML = svg;
if (this.hasSmdSpecTarget) {
this.smdSpecTarget.textContent =
`${pkgKey} (${pkg.metric}) · ${this.formatMm(pkg.l)} × ${this.formatMm(pkg.w)} · ${this.formatPower(pkg.power)}`;
}
}
smdPackageValue() {
const v = this.hasSmdPackageTarget ? this.smdPackageTarget.value : "0805";
return SMD_PACKAGES[v] ? v : "0805";
}
/*
* ---------------------------------------------------------------
* SMD capacitor tab (interactive) — an MLCC chip. These are (almost) always unmarked, so there
* is nothing to decode: you enter the value + package and it draws the picture to attach.
* ---------------------------------------------------------------
*/
/** Reads the SMD-capacitor value input and redraws the MLCC chip. */
syncSmdCap() {
if (!this.hasSmdCapSvgTarget) {
return;
}
const raw = this.hasSmdCapValueInputTarget ? this.smdCapValueInputTarget.value : "100n";
const farads = this.parseValue(raw, "F");
if (farads === null || !(farads > 0)) {
if (this.hasSmdCapValueInputTarget) {
this.smdCapValueInputTarget.classList.toggle("is-invalid", (raw || "").trim() !== "");
}
this.smdCapSvgTarget.innerHTML = "";
return;
}
if (this.hasSmdCapValueInputTarget) {
this.smdCapValueInputTarget.classList.remove("is-invalid");
}
this.smdCapPf = farads * 1e12; //formatFarads() works in picofarads
this.drawSmdCapacitor(this.smdCapSvgTarget, {
package: this.smdCapPackageValue(),
bodyColor: this.hasSmdCapBodyColorTarget ? this.smdCapBodyColorTarget.value : null,
voltage: this.hasSmdCapVoltageTarget ? this.smdCapVoltageTarget.value : null,
tolerance: this.hasSmdCapToleranceTarget ? this.smdCapToleranceTarget.value : null,
specEl: this.hasSmdCapSpecTarget ? this.smdCapSpecTarget : null,
});
}
smdCapPackageValue() {
const v = this.hasSmdCapPackageTarget ? this.smdCapPackageTarget.value : "0805";
return SMD_PACKAGES[v] ? v : "0805";
}
applySmdCapBodyColor(event) {
if (this.hasSmdCapBodyColorTarget) {
this.smdCapBodyColorTarget.value = event.currentTarget.dataset.color;
}
this.syncSmdCap();
}
/**
* Draws a surface-mount MLCC capacitor: a tan ceramic block with wide metal end terminations and
* (as on real MLCCs) no printed marking — the decoded value is shown as a caption below instead.
*/
drawSmdCapacitor(target, options = {}) {
const uid = this.svgId();
const w = 300;
const pkgKey = SMD_PACKAGES[options.package] ? options.package : "0805";
const pkg = SMD_PACKAGES[pkgKey];
const bodyW = Math.round(120 + 90 * (pkg.l - 0.6) / (6.3 - 0.6));
const aspect = pkg.l / pkg.w;
const bodyH = Math.max(48, Math.min(122, Math.round(bodyW / aspect)));
//MLCC end terminations are noticeably wider than a chip resistor's.
const termW = Math.max(20, Math.round(bodyW * 0.20));
const cx = w / 2;
const bodyX = Math.round(cx - bodyW / 2);
const bodyY = Math.round(78 - bodyH / 2);
const bodyBottom = bodyY + bodyH;
const innerX = bodyX + termW;
const innerW = bodyW - 2 * termW;
const fill = this.safeColor(options.bodyColor, "#c8a37a");
const callouts =
this.dimH(bodyX, bodyX + bodyW, bodyBottom + 18, `L ${this.formatMm(pkg.l)}`)
+ this.dimV(bodyY, bodyBottom, bodyX + bodyW + 16, `W ${this.formatMm(pkg.w)}`, bodyX + bodyW);
const valueLabel = this.smdCapPf > 0 ? this.formatFarads(this.smdCapPf) + this.specSuffix(options) : "";
const h = bodyBottom + 54;
const valueCaption = valueLabel
? `<text x="${cx}" y="${h - 12}" text-anchor="middle" font-family="monospace" font-size="16" font-weight="700" fill="#3a4149">${valueLabel}</text>`
: "";
target.innerHTML = `
<svg viewBox="0 0 ${w} ${h}" xmlns="http://www.w3.org/2000/svg" style="max-width: 300px; width: 100%; height: auto;">
<defs>
${this.metalGradient(uid)}
${this.glossGradient(uid)}
${this.blurFilter(uid)}
<clipPath id="${uid}clip"><rect x="${bodyX}" y="${bodyY}" width="${bodyW}" height="${bodyH}" rx="10" ry="10"/></clipPath>
${this.shadowFilter(uid)}
</defs>
<g>
<rect x="${bodyX}" y="${bodyY}" width="${bodyW}" height="${bodyH}" rx="10" ry="10" fill="url(#${uid}metal)" stroke="#00000055" stroke-width="1" filter="url(#${uid}shadow)"/>
<g clip-path="url(#${uid}clip)">
<rect x="${innerX}" y="${bodyY}" width="${innerW}" height="${bodyH}" fill="${fill}"/>
<rect x="${innerX}" y="${bodyY}" width="${innerW}" height="${bodyH}" fill="url(#${uid}gloss)"/>
<rect x="${innerX}" y="${bodyY + 2}" width="${innerW}" height="3" fill="#ffffff" opacity="0.22"/>
<rect x="${innerX - 2}" y="${bodyY}" width="3" height="${bodyH}" fill="#000000" opacity="0.25"/>
<rect x="${innerX + innerW - 1}" y="${bodyY}" width="3" height="${bodyH}" fill="#000000" opacity="0.25"/>
</g>
</g>
${callouts}
${valueCaption}
</svg>`;
if (options.specEl) {
options.specEl.textContent =
`${pkgKey} (${pkg.metric}) · ${this.formatMm(pkg.l)} × ${this.formatMm(pkg.w)}${valueLabel ? ` · ${valueLabel}` : ""}`;
}
}
/*
* ---------------------------------------------------------------
* SMD inductor tab (interactive)
* ---------------------------------------------------------------
*/
/**
* Recomputes the linked SMD-inductor value/code fields (and the picture) from whichever was
* edited — typing a value fills the code, and (like the SMD resistor tab) typing a code fills
* the value. Called with no event for programmatic redraws (package/colour change, init).
*/
syncSmdInductor(event) {
if (!this.hasSmdIndSvgTarget) {
return;
}
const field = event && event.currentTarget && event.currentTarget.dataset ? event.currentTarget.dataset.field : null;
const activeInput = field === "code" && this.hasSmdIndCodeTarget ? this.smdIndCodeTarget
: (this.hasSmdIndValueInputTarget ? this.smdIndValueInputTarget : null);
let henries;
if (field === "code") {
henries = this.inductorCodeToHenries(this.hasSmdIndCodeTarget ? this.smdIndCodeTarget.value : "");
} else {
const raw = (this.hasSmdIndValueInputTarget ? this.smdIndValueInputTarget.value : "100u").trim();
//Accept "100µH", "10mH", "4.7uH"; a bare number (no prefix) reads as µH, matching the THT inductor tab.
const m = raw.match(/^([\d.]+)\s*(p|n|u|µ|m)?\s*h?$/i);
if (m) {
const num = parseFloat(m[1]);
const factors = {p: 1e-12, n: 1e-9, u: 1e-6, "µ": 1e-6, m: 1e-3};
henries = m[2] ? num * factors[m[2].toLowerCase()] : num * 1e-6;
} else {
henries = null;
}
}
if (henries === null || !(henries > 0)) {
if (activeInput) {
activeInput.classList.toggle("is-invalid", activeInput.value.trim() !== "");
}
this.smdIndSvgTarget.innerHTML = "";
return;
}
if (activeInput) {
activeInput.classList.remove("is-invalid");
}
const marking = this.henriesToInductorCode(henries / 1e-6);
if (field !== "code" && this.hasSmdIndCodeTarget) {
this.smdIndCodeTarget.value = marking;
}
if (field !== "value" && this.hasSmdIndValueInputTarget) {
this.smdIndValueInputTarget.value = this.formatHenries(henries);
}
this.drawSmdInductor(this.smdIndSvgTarget, marking, henries, {
package: this.smdIndPackageValue(),
bodyColor: this.hasSmdIndBodyColorTarget ? this.smdIndBodyColorTarget.value : null,
specEl: this.hasSmdIndSpecTarget ? this.smdIndSpecTarget : null,
});
}
/**
* Parses an SMD-inductor marking back to henries: R-notation (4R7 = 4.7 µH) or the 3-digit EIA
* code (101 = 100 µH), the same two forms {@see henriesToInductorCode} prints. Returns null for
* anything else (e.g. a 4-digit or EIA-96 code, which this chip type isn't drawn with).
*/
inductorCodeToHenries(raw) {
const code = (raw || "").trim().toUpperCase();
if (code === "") {
return null;
}
if (code.includes("R") && /^\d*R\d*$/.test(code)) {
const v = parseFloat(code.replace("R", "."));
return Number.isNaN(v) ? null : v * 1e-6;
}
if (/^\d{3}$/.test(code)) {
const uH = parseInt(code.substring(0, 2), 10) * Math.pow(10, parseInt(code.charAt(2), 10));
return uH * 1e-6;
}
return null;
}
smdIndPackageValue() {
const v = this.hasSmdIndPackageTarget ? this.smdIndPackageTarget.value : "1210";
return SMD_PACKAGES[v] ? v : "1210";
}
applySmdIndBodyColor(event) {
if (this.hasSmdIndBodyColorTarget) {
this.smdIndBodyColorTarget.value = event.currentTarget.dataset.color;
}
this.syncSmdInductor();
}
/*
* ---------------------------------------------------------------
* Diode tab (interactive)
* ---------------------------------------------------------------
*/
/*
* ---------------------------------------------------------------
* Helpers
* ---------------------------------------------------------------
*/
/**
* Parses a human entered value like "4k7", "4.7k", "100n", "1M5" into a
* plain number. baseUnit is "R" (ohms) or "F" (farads) and is used to strip
* a trailing unit symbol. Returns null if it can't be parsed.
*/
parseValue(raw, baseUnit) {
if (raw === null || raw === undefined) {
return null;
}
// Keep the original case: the prefix "m" (milli) and "M" (mega) must stay distinct.
let s = raw.trim();
if (s === "") {
return null;
}
// Strip a trailing unit symbol (ohm, ω, f) — matched case-insensitively.
s = s.replace(/ohm[s]?$/i, "").replace(/Ω/gi, "").trim();
if (baseUnit === "F") {
s = s.replace(/farad[s]?$/i, "").replace(/f$/i, "").trim();
}
// RKM style: prefix used as decimal separator, e.g. 4k7, 1R5, 2u2, 4M7
let m = s.match(/^(\d+)\s*(p|n|u|µ|m|k|meg|g|r)\s*(\d+)$/i);
if (m) {
const factor = this.prefixFactor(m[2]);
return factor === null ? null : parseFloat(`${m[1]}.${m[3]}`) * factor;
}
// Number followed by an optional prefix, e.g. 4.7k, 100n, 470, 10M
m = s.match(/^([\d.]+)\s*(p|n|u|µ|m|k|meg|g|r)?$/i);
if (m) {
const num = parseFloat(m[1]);
if (Number.isNaN(num)) {
return null;
}
const factor = this.prefixFactor(m[2]);
return factor === null ? null : num * factor;
}
return null;
}
/**
* Resolves an SI prefix (or the RKM "R" separator) to a multiplication factor.
* Case sensitive only for m (milli) vs M (mega); all other prefixes are
* case-insensitive. Returns 1 for "no prefix"/R, or null for an unknown prefix.
*/
prefixFactor(prefix) {
if (prefix === undefined || prefix === "" || prefix.toLowerCase() === "r") {
return 1;
}
if (prefix === "m") {
return 1e-3;
}
if (prefix === "M") {
return 1e6;
}
const factors = {p: 1e-12, n: 1e-9, u: 1e-6, "µ": 1e-6, k: 1e3, meg: 1e6, g: 1e9};
return factors[prefix.toLowerCase()] ?? null;
}
formatOhms(ohms) {
return this.formatWithPrefix(ohms, "Ω", false);
}
/**
* Formats a capacitance. The input value is always given in picofarads.
* When pfForm is true, the value is rendered in plain pF, otherwise the most
* fitting SI prefix (pF/nF/µF/mF/F) is used.
*/
formatFarads(pf, pfForm = false) {
if (pfForm) {
return `${this.trimNumber(pf)} pF`;
}
return this.formatWithPrefix(pf * 1e-12, "F", true);
}
formatWithPrefix(value, unit, isFarad) {
if (value === 0) {
return `0 ${unit}`;
}
const steps = isFarad
? [[1e-12, "p"], [1e-9, "n"], [1e-6, "µ"], [1e-3, "m"], [1, ""]]
: [[1e-3, "m"], [1, ""], [1e3, "k"], [1e6, "M"], [1e9, "G"]];
let chosen = steps[0];
for (const step of steps) {
if (value >= step[0]) {
chosen = step;
}
}
return `${this.trimNumber(value / chosen[0])} ${chosen[1]}${unit}`;
}
trimNumber(num) {
return parseFloat(num.toFixed(3)).toString();
}
}