/* * 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 . */ 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(" 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) => ``) .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 += `
`; }); 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 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 += ``; }); 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 ? `${resistanceLabel}` : ""); const svg = ` ${this.leadGradient(uid)} ${this.cylinderGradient(uid)} ${this.endVignetteGradient(uid)} ${this.blurFilter(uid)} ${this.shadowFilter(uid)} ${bands} ${callouts} `; 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 += ``; }); 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 = `${this.formatHenries(henries)}${this.specSuffix(spec)}`; tgt.innerHTML = ` ${this.leadGradient(uid)} ${this.cylinderGradient(uid)} ${this.endVignetteGradient(uid)} ${this.blurFilter(uid)} ${this.shadowFilter(uid)} ${bands} ${callouts} `; } /** 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 = `${this.formatHenries(henries)}${this.specSuffix(options)}`; target.innerHTML = ` ${this.metalGradient(uid)} ${this.glossGradient(uid)} ${this.shadowFilter(uid)} ${this.escapeXml(marking)} ${callouts} ${valueCaption} `; 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 ? `${this.escapeXml(marking)}` : ""; const belowCaption = caption === "" ? "" : `${caption}`; target.innerHTML = ` ${this.leadGradient(uid)} ${this.cylinderGradient(uid)} ${this.endVignetteGradient(uid)} ${this.blurFilter(uid)} ${this.shadowFilter(uid)} ${bodyMarking} ${belowCaption} `; } /** 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 = ` ${this.leadGradient(uid)} ${this.blurFilter(uid)} ${this.shadowFilter(uid)} LED `; } 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 ? `${voltage}V` : ""; // Leads. const leadTop = bodyBottom - 6; const leadEnd = bodyBottom + leadExtra; const leadW = 3.4; const leads = `` + ``; 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 ? `${valueLabel}` : ""; const svg = ` ${this.leadGradient(uid)} ${this.blurFilter(uid)} ${leads} ${this.escapeXml(marking)} ${voltageSvg} ${callouts} ${valueCaption} `; 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 ` `; } /** Vertical metallic gradient for SMD terminations. */ metalGradient(uid) { return ` `; } /** Top-light / bottom-dark overlay that turns a flat shape into a cylinder. */ cylinderGradient(uid) { return ` `; } /** Softer top-gloss overlay for caps and SMD bodies. */ glossGradient(uid) { return ` `; } /** Radial highlight used as a specular reflection on the cap body. */ specularGradient(uid) { return ` `; } /** Horizontal vignette that darkens the rounded ends of a cylinder. */ endVignetteGradient(uid) { return ` `; } /** Soft gaussian blur, used for specular streaks and ground shadows. */ blurFilter(uid) { return ` `; } /** 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 element * is NOT on the sanitizer's filter-primitive allow-list and gets stripped on save, leaving an * empty — 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 ` `; } /** Horizontal dimension line with end ticks, arrows and a centered label above. */ dimH(x1, x2, y, label) { const t = 4; return ` ${label} `; } /** Vertical dimension line (label centered above) with optional extension lines. */ dimV(y1, y2, x, label, extFromX = null) { const t = 4; const ext = extFromX === null ? "" : ` `; return ` ${ext} ${label} `; } /** 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) => ({"&": "&", "<": "<", ">": ">", '"': """, "'": "'"})[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 ? `${valueLabel}` : ""; const svg = ` ${this.metalGradient(uid)} ${this.glossGradient(uid)} ${this.blurFilter(uid)} ${this.shadowFilter(uid)} ${this.escapeXml(marking)} ${callouts} ${valueCaption} `; 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 ? `${valueLabel}` : ""; target.innerHTML = ` ${this.metalGradient(uid)} ${this.glossGradient(uid)} ${this.blurFilter(uid)} ${this.shadowFilter(uid)} ${callouts} ${valueCaption} `; 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(); } }