Magnet Designer / Calculator

Design your magnet.
See the pull and other info about it.

Pick a shape, set your dimensions and grade, and get an instant estimated holding force. When it's right, send the spec straight to our engineering team for a formal quote.

material —NdFeB
std —AIAG / IATF
lead time —5–6 wks
PREVIEW

Spec sheet

RM-0000
lbs
Estimated max pull force, steel-to-steel direct contact
ShapeDisc
Dimensions
Magnetization
Tolerance±0.1mm
Volume
GradeN38
Surface field— G
Heat seriesStandard (N)
CoatingNi-Cu-Ni
Max operating temp
Quantity100 pcs

Pull force is a planning estimate from grade and geometry, not a lab measurement. Actual holding force depends on the mating surface, air gap, and orientation — we'll confirm exact figures during quoting.

Surface field (Gauss) is calculated on-axis at the magnetized pole face — exact physics for axial/thickness-direction magnets, an approximation for diametric/width/length directions (marked "approx."). Real magnets vary by manufacturing tolerance; for sensor-trigger or other field-critical applications, confirm with a Gauss meter on a production sample.

← Return to radialmagnet.com
`; } $("exportPdfOption").addEventListener("click", () => { const html = buildPrintableSpecSheetHtml(); const win = window.open("", "_blank"); if(!win){ showToast("Your browser blocked the new tab — allow popups for this site to use this feature"); return; } win.document.write(html); win.document.close(); exportModalOverlay.classList.remove("open"); logDesignEvent("save", serializeDesign(), $("specId").textContent); }); const exportModalOverlay = $("exportModalOverlay"); $("exportDrawingBtn").addEventListener("click", () => exportModalOverlay.classList.add("open")); $("exportModalClose").addEventListener("click", () => exportModalOverlay.classList.remove("open")); exportModalOverlay.addEventListener("click", e => { if(e.target === exportModalOverlay) exportModalOverlay.classList.remove("open"); }); // ---------- share a design via URL ---------- // This standalone page has no server to store designs against a short // ID, so the whole design is encoded straight into the URL instead. // Anyone who opens the link gets it decoded and applied automatically -- // no account, no backend, works completely offline once the page loads. function encodeDesignForUrl(design){ const json = JSON.stringify(design); // encodeURIComponent first so btoa (which only handles Latin1) doesn't // choke on any non-ASCII characters that might end up in the JSON. return btoa(encodeURIComponent(json)); } function decodeDesignFromUrl(encoded){ try{ const json = decodeURIComponent(atob(encoded)); return JSON.parse(json); }catch(err){ return null; } } const shareOverlay = $("shareModalOverlay"); $("shareDesignBtn").addEventListener("click", () => { const design = serializeDesign(); const encoded = encodeDesignForUrl(design); const url = new URL(window.location.href); url.search = ""; url.searchParams.set("design", encoded); $("shareLinkInput").value = url.toString(); shareOverlay.classList.add("open"); logDesignEvent("share", design, $("specId").textContent); }); $("shareModalClose").addEventListener("click", () => shareOverlay.classList.remove("open")); shareOverlay.addEventListener("click", e => { if(e.target === shareOverlay) shareOverlay.classList.remove("open"); }); $("copyShareLinkBtn").addEventListener("click", async () => { const input = $("shareLinkInput"); try{ await navigator.clipboard.writeText(input.value); }catch(err){ // Clipboard API can be blocked in some contexts; fall back to manual select. input.select(); } showToast("Link copied"); }); function loadDesignFromUrlIfPresent(){ const params = new URLSearchParams(window.location.search); const encoded = params.get("design"); if(!encoded) return; const design = decodeDesignFromUrl(encoded); if(design && applyDesign(design)){ showToast("Loaded shared design"); }else{ showToast("That shared design link couldn't be loaded"); } } // ---------- shape preview svg ---------- function renderShapeSvg(){ const svg = $("shapeSvg"); const stroke = "#02ce06"; const fillFaint = "rgba(2,206,6,0.08)"; const fieldStroke = "rgba(154,224,160,0.55)"; const fieldStrokeActive = "rgba(120,235,135,0.85)"; // Field-line loops are hand-placed per shape/magnetization rather than // computed from a generic formula -- the body silhouette differs enough // between shapes (flat ellipse vs. isometric box vs. cylinder) that an // automatic perpendicular-bow offset frequently grazes or crosses the // outline instead of clearly looping outside it. This is illustrative, // matching whichever magnetization direction is selected, not a // precise field simulation. function fieldPath(d, active){ return ``; } function poleLabel(p, letter, offsetX, offsetY){ const color = letter === "N" ? stroke : "#9aa0a6"; return ` ${letter} `; } let html = ""; let fieldHtml = ""; let poleHtml = ""; if(shape === "disc" || shape === "ring"){ const isRing = shape === "ring"; const bodyBottom = ``; const bodySide = ` `; const hole = isRing ? `` : ""; html = bodyBottom + bodySide + hole; if(magnetization === "axial"){ // Poles on the flat top/bottom faces. Loops arc out past the left // and right edges of the disc, well clear of its 55-unit radius. const top = {x:100, y:67}, bottom = {x:100, y:118}; fieldHtml = fieldPath(`M100,67 C150,67 180,90 180,92.5 C180,95 150,118 100,118`, true) + fieldPath(`M100,67 C20,67 -10,90 -10,92.5 C-10,95 20,118 100,118`, false); poleHtml = poleLabel(top, "N", 0, 0) + poleLabel(bottom, "S", 0, 0); }else{ // Diametric: poles on opposite edges of the rim. Loops arc above // and below the disc's flat faces. const left = {x:32, y:92}, right = {x:168, y:92}; fieldHtml = fieldPath(`M32,92 C60,40 140,40 168,92`, true) + fieldPath(`M32,92 C60,144 140,144 168,92`, false); poleHtml = poleLabel(left, "N", -10, 0) + poleLabel(right, "S", 10, 0); } } else if(shape === "block"){ const topFace = ``; const leftFace = ``; const frontFace = ``; const rightFace = ``; html = leftFace + frontFace + rightFace + topFace; if(magnetization === "thickness"){ // Poles on top/bottom (largest faces). Loops arc out past the // right side, clear of the block's silhouette. const top = {x:117, y:64}, bottom = {x:117, y:136}; fieldHtml = fieldPath(`M117,64 C185,72 195,92 195,100 C195,108 185,128 117,136`, true) + fieldPath(`M117,64 C55,72 50,92 50,100 C50,108 55,128 117,136`, false); poleHtml = poleLabel(top, "N", 0, -4) + poleLabel(bottom, "S", 0, 6); }else if(magnetization === "width"){ // Poles on left/right faces. Loops arc above and below the block. const left = {x:62, y:100}, right = {x:172, y:100}; fieldHtml = fieldPath(`M62,100 C80,40 155,40 172,100`, true) + fieldPath(`M62,100 C80,160 155,160 172,100`, false); poleHtml = poleLabel(left, "N", -10, 0) + poleLabel(right, "S", 10, 0); }else{ // Through length: poles on the front face (visible, lower) and the // back face (hidden behind the block in this isometric view, upper). // Loops arc out to the left, clear of the silhouette, to show the // two faces are linked without tracing a literal hidden-line path. const front = {x:117, y:142}, back = {x:108, y:58}; fieldHtml = fieldPath(`M117,142 C30,135 15,110 15,100 C15,90 30,65 108,58`, true) + fieldPath(`M117,142 C205,128 195,108 190,100 C195,92 205,72 108,58`, false); poleHtml = poleLabel(front, "N", 0, 10) + poleLabel(back, "S", 0, -10); } } else if(shape === "rod"){ const topCap = ``; const body = ``; const bottomCap = ``; html = body + bottomCap + topCap; if(magnetization === "axial"){ // Poles on the two end caps. Loops arc out past the sides of the // rod, clear of its 22-unit cap radius. const top = {x:100, y:40}, bottom = {x:100, y:160}; fieldHtml = fieldPath(`M100,40 C155,48 165,80 165,100 C165,120 155,152 100,160`, true) + fieldPath(`M100,40 C45,48 35,80 35,100 C35,120 45,152 100,160`, false); poleHtml = poleLabel(top, "N", 0, -2) + poleLabel(bottom, "S", 0, 2); }else{ // Diametric: poles on opposite sides of the round body. Loops arc // above and below the rod. const left = {x:64, y:100}, right = {x:136, y:100}; fieldHtml = fieldPath(`M64,100 C75,55 125,55 136,100`, true) + fieldPath(`M64,100 C75,145 125,145 136,100`, false); poleHtml = poleLabel(left, "N", -10, 0) + poleLabel(right, "S", 10, 0); } } // Field arcs render ON TOP of the body so they're actually visible // (the magnet's solid faces would otherwise hide them entirely). svg.innerHTML = `${html}${fieldHtml}${poleHtml}`; } // ---------- physics-ish estimate ---------- // Volume in mm^3, simplified max-pull-force model scaled by grade Br and a // geometry factor; calibrated against typical small NdFeB disc datasheets. function computeVolumeMm3(){ if(shape === "disc"){ const r = dims.diameter/2; return Math.PI * r*r * dims.thickness; } if(shape === "ring"){ const rOut = dims.outerDiameter/2, rIn = dims.innerDiameter/2; return Math.PI * (rOut*rOut - Math.max(rIn,0)*Math.max(rIn,0)) * dims.thickness; } if(shape === "block"){ return dims.length * dims.width * dims.thickness; } if(shape === "rod"){ const r = dims.diameter/2; return Math.PI * r*r * dims.length; } return 0; } // Characteristic in-plane size of the magnetized pole face, used to scale // how thick the steel needs to be before it stops limiting pull force. // This isn't exact for every magnetization direction (e.g. a block // magnetized through its length presents its end face, not its // length x width face) but it's a reasonable planning-level proxy -- // bigger pole face needs thicker steel to avoid saturating, smaller // pole face needs less, which is the real qualitative relationship. function poleFaceDimensionMm(){ if(shape === "disc") return dims.diameter; if(shape === "ring") return dims.outerDiameter; if(shape === "block") return Math.max(dims.length, dims.width); if(shape === "rod") return dims.diameter; return 10; } // Air-gap derating: any standoff between the magnet face and the steel // (paint, plating, tape, rust, a spacer) reduces pull force steeply at // first and then more gradually -- this shape and the 0.7/1.6 constants // are fit against published reference points (e.g. a ~0.1mm gap cutting // pull by roughly 20-30%, a 2mm gap cutting it by roughly 90%), not a // first-principles field solution. It's a planning estimate, same as // the rest of this calculator. function airGapFactor(gapMm){ if(gapMm <= 0) return 1; return 1 / Math.pow(1 + gapMm/0.7, 1.6); } // Steel-thickness derating: thin steel saturates and can't carry the // full field, so pull force ramps up from ~0 toward full strength as // the plate gets thicker, leveling off once it's no longer the limiting // factor. The thickness needed to reach full strength scales with the // magnet's pole-face size (roughly 13% of it, per published examples of // small disc/block magnets and their measured saturation thickness). function steelThicknessFactor(steelMm, poleFaceMm){ const fullForceMm = Math.max(0.3, poleFaceMm * 0.13); if(steelMm <= 0) return 0; const ratio = steelMm / fullForceMm; return 1 - Math.exp(-Math.pow(ratio/0.55, 1.3)); } function computePullLbs(){ const mat = MATERIALS[material]; const gradeKey = $("gradeSelect").value; const heatKey = $("heatSelect").value; const grade = mat.grades[gradeKey] || Object.values(mat.grades)[0]; const heat = mat.heatSeries[heatKey] || Object.values(mat.heatSeries)[0]; const br = grade.br * heat.brFactor; const volMm3 = computeVolumeMm3(); const volCm3 = volMm3 / 1000; // Empirical-style scaling: pull (lbs) ≈ k * Br^2 * volume(cm^3)^0.62 // k chosen so a 10mm dia x 5mm N38 NdFeB disc (~0.39 cm^3) lands near // common commercial datasheet values (~5-6 lbs). const k = mat.pullScale; let pull = k * br*br * Math.pow(Math.max(volCm3,0.001), 0.62); if(shape === "ring"){ pull *= 0.82; } // hole reduces effective face area // Pull force is specifically the force at the magnetized poles -- pulling // through a different axis than the one that's magnetized gives a much // weaker, fringing-field force in reality, so derate accordingly. const magOption = MAGNETIZATION[shape] && MAGNETIZATION[shape][magnetization]; if(magOption) pull *= magOption.pullFactor; // Application-surface derating: air gap and steel thickness. Both // default to "ideal" (0 gap, thick steel) so this is a no-op unless // the surface fields have actually been opened and changed. if(!idealSurface){ pull *= airGapFactor(airGapMm); pull *= steelThicknessFactor(steelThicknessMm, poleFaceDimensionMm()); } return pull; } // ---------- surface field strength (Gauss) ---------- // On-axis field formula for a uniformly magnetized cylinder, evaluated // at the pole face (z=0) or at a distance z along the magnetization // axis: B(z) = (Br/2) * [ (L+z)/sqrt(R^2+(L+z)^2) - z/sqrt(R^2+z^2) ]. // This is exact closed-form physics for the on-axis case (not a curve // fit) -- validated against two independent published reference points // before being added here: a K&J Magnetics B444 cube (predicted 604.5G // at 0.25in vs their reported 599G), and a First4Magnets 25mm x 20mm // N52 disc (predicted 6148G vs their cited ~6250G surface field). function onAxisFieldGauss(brTesla, rMm, lMm, zMm){ const term1 = (lMm + zMm) / Math.sqrt(rMm*rMm + (lMm+zMm)*(lMm+zMm)); const term2 = zMm === 0 ? 0 : zMm / Math.sqrt(rMm*rMm + zMm*zMm); return (brTesla/2) * (term1 - term2) * 10000; // Tesla -> Gauss } // Ring (annular) surface field. The textbook approach (outer-cylinder // field minus inner-cylinder field, evaluated on the central axis) is // mathematically exact but measures the WRONG point for this app's // purposes: the central axis of a ring passes through the open bore, // not through any actual magnet material. Ring magnets are documented // to have a field that genuinely reverses sign on that axis near the // pole face (confirmed experimentally in the academic source this // formula comes from), which produces nonsensical negative "surface // field" results for many real OD/ID/thickness combinations -- not a // formula bug, but the wrong measurement location for what a Gauss // meter against the actual annular face would read. // Instead, this treats the ring the same way the pull-force model // already treats it: compute the on-axis field of a solid disc using // the OUTER radius (always well-behaved, no sign reversal), then derate // for the missing material in the bore, proportional to how much of the // face area the hole removes. This is consistent with the ring's // existing pull-force treatment (×0.82 fixed derate for "hole reduces // effective face area") but scales with the actual bore size here // rather than using a fixed constant, since Gauss is more sensitive to // bore size than pull force is. function onAxisFieldGaussRing(brTesla, raMm, riMm, dMm, zMm){ const solidEquivalent = onAxisFieldGauss(brTesla, raMm, dMm, zMm); const areaFractionRemoved = Math.pow(riMm / raMm, 2); return solidEquivalent * (1 - areaFractionRemoved); } // Equivalent-area-circle radius for a rectangular cross-section, so a // block can reuse the cylinder formula. This is a documented // approximation (used by published Gauss calculators for the same // purpose), accurate for roughly square cross-sections and losing // accuracy for very elongated rectangles. function equivalentRadiusMm(aMm, bMm){ return Math.sqrt((aMm * bMm) / Math.PI); } // Computes the on-axis surface field for whichever shape/magnetization // is currently configured. Only axial-style (primary) magnetization // directions have a clean on-axis formula -- diametric/width/length // directions place the poles on the side rather than the end, so there's // no equally simple closed-form result available here. For those, this // reuses the same relative pullFactor already defined per magnetization // direction as a rough proxy (consistent with how the pull-force model // already treats off-axis directions as weaker), and the UI flags those // results as a rougher estimate than the on-axis ones. Rings use the // disc-equivalent + area-derating approach described above rather than // the textbook central-axis ring formula, for the reasons noted there. function computeSurfaceGauss(distanceMm){ const mat = MATERIALS[material]; const gradeKey = $("gradeSelect").value; const heatKey = $("heatSelect").value; const grade = mat.grades[gradeKey] || Object.values(mat.grades)[0]; const heat = mat.heatSeries[heatKey] || Object.values(mat.heatSeries)[0]; const br = grade.br * heat.brFactor; const z = Math.max(0, distanceMm || 0); const magOptions = MAGNETIZATION[shape]; const magOption = magOptions && magOptions[magnetization]; const isPrimaryAxis = magOption && magOption.pullFactor === 1.0; let gauss; if(shape === "disc"){ gauss = onAxisFieldGauss(br, dims.diameter/2, dims.thickness, z); }else if(shape === "rod"){ gauss = onAxisFieldGauss(br, dims.diameter/2, dims.length, z); }else if(shape === "ring"){ gauss = onAxisFieldGaussRing(br, dims.outerDiameter/2, dims.innerDiameter/2, dims.thickness, z); }else if(shape === "block"){ // The two in-plane dimensions perpendicular to the magnetization // axis become the equivalent circle; the dimension along the // magnetization axis is the cylinder "length" in the formula. let a, b, l; if(magnetization === "thickness"){ a = dims.length; b = dims.width; l = dims.thickness; } else if(magnetization === "width"){ a = dims.length; b = dims.thickness; l = dims.width; } else { a = dims.width; b = dims.thickness; l = dims.length; } gauss = onAxisFieldGauss(br, equivalentRadiusMm(a,b), l, z); }else{ gauss = 0; } // Off-axis directions (diametric, width, length when not the primary // axis) don't fit the on-axis formula's assumptions -- approximate // with the existing relative pullFactor rather than a separate // invented field model. if(magOption && !isPrimaryAxis){ gauss *= magOption.pullFactor; } return { gauss, isPrimaryAxis: !!isPrimaryAxis }; } function fmtMm(v){ return (Math.round(v*10)/10).toString(); } function fmtUnitVal(v){ // dims are always stored in mm internally; convert for display when // the user has switched the unit toggle to inches. if(unit === "mm") return fmtMm(v); return (Math.round(mmToIn(v)*1000)/1000).toString(); } function fmtDims(){ const u = unit; if(shape === "disc") return `⌀${fmtUnitVal(dims.diameter)} × ${fmtUnitVal(dims.thickness)} ${u}`; if(shape === "ring") return `⌀${fmtUnitVal(dims.outerDiameter)}/⌀${fmtUnitVal(dims.innerDiameter)} × ${fmtUnitVal(dims.thickness)} ${u}`; if(shape === "block") return `${fmtUnitVal(dims.length)} × ${fmtUnitVal(dims.width)} × ${fmtUnitVal(dims.thickness)} ${u}`; if(shape === "rod") return `⌀${fmtUnitVal(dims.diameter)} × ${fmtUnitVal(dims.length)} ${u}`; } function genSpecId(){ const mat = MATERIALS[material]; const gradeKey = $("gradeSelect").value; const matTag = material.slice(0,2).toUpperCase(); const tag = shape.slice(0,2).toUpperCase(); const hash = Math.abs(Math.round(computeVolumeMm3())) % 900 + 100; return `RM-${matTag}${tag}${gradeKey}-${hash}`; } function fmtTolerance(){ const tol = TOLERANCES[tolerance]; if(unit === "mm") return `±${tol.valueMm}mm`; const inVal = Math.round(mmToIn(tol.valueMm)*1000)/1000; return `±${inVal}in`; } // Most quantity steps are plain numbers; the top step is the sentinel // string "1000000+" for bulk/RFQ-style orders beyond a fixed number. function fmtQty(qty){ return typeof qty === "number" ? qty.toLocaleString() : qty; } function render(){ renderShapeSvg(); const mat = MATERIALS[material]; const gradeKey = $("gradeSelect").value; const grade = mat.grades[gradeKey] || Object.values(mat.grades)[0]; const plating = $("platingSelect").value; const platingLabel = (mat.plating.find(p => p.value === plating) || mat.plating[0]).label; const heatKey = $("heatSelect").value; const heat = mat.heatSeries[heatKey] || Object.values(mat.heatSeries)[0]; const tol = TOLERANCES[tolerance]; const magOptions = MAGNETIZATION[shape]; const magOption = magOptions[magnetization] || Object.values(magOptions)[0]; const qtyIdx = parseInt($("qtySlider").value, 10); const qty = qtySteps[qtyIdx - 1]; $("qtyVal").textContent = fmtQty(qty); $("heatTempOut").textContent = heat.maxTemp + "°C"; $("heatNote").textContent = heat.note; $("toleranceNote").textContent = tol.note; $("magnetizationNote").textContent = magOption.note; const pull = computePullLbs(); $("pullForce").innerHTML = `${Math.round(pull*10)/10} lbs`; const pct = Math.min(100, (pull / 60) * 100); $("pullBarFill").style.width = pct + "%"; if(idealSurface){ $("pullSub").textContent = "Estimated max pull force, steel-to-steel direct contact"; $("rowSurfaceWrap").style.display = "none"; }else{ const gapDisplay = unit === "mm" ? `${fmtMm(airGapMm)}mm gap` : `${fmtUnitVal(airGapMm)}in gap`; const steelDisplay = unit === "mm" ? `${fmtMm(steelThicknessMm)}mm steel` : `${fmtUnitVal(steelThicknessMm)}in steel`; $("pullSub").textContent = `Estimated pull force at ${gapDisplay}, ${steelDisplay}`; $("rowSurfaceWrap").style.display = "flex"; $("rowSurface").textContent = `${gapDisplay}, ${steelDisplay}`; } $("rowShape").textContent = SHAPES[shape].label; $("rowDims").textContent = fmtDims(); $("rowMagnetization").textContent = magOption.label; $("rowTolerance").textContent = fmtTolerance(); const volCm3 = computeVolumeMm3()/1000; $("rowVolume").textContent = `${(Math.round(volCm3*100)/100)} cm³`; $("rowGrade").textContent = `${mat.label.split(" (")[0]} — ${grade.label}`; const gaussDistanceMm = idealSurface ? 0 : airGapMm; const { gauss, isPrimaryAxis } = computeSurfaceGauss(gaussDistanceMm); const gaussRounded = Math.round(gauss / 10) * 10; // round to nearest 10G, matching typical datasheet precision const gaussLabel = gaussDistanceMm > 0 ? `Field at ${unit === "mm" ? fmtMm(gaussDistanceMm) + "mm" : fmtUnitVal(gaussDistanceMm) + "in"} gap` : "Surface field"; $("rowGaussLabel").textContent = gaussLabel; $("rowGauss").textContent = `${gaussRounded.toLocaleString()} G${isPrimaryAxis ? "" : " (approx.)"}`; $("rowHeatSeries").textContent = Object.keys(mat.heatSeries).length > 1 ? heat.label : "—"; $("rowPlating").textContent = platingLabel; $("rowTemp").textContent = heat.maxTemp + "°C"; $("rowQty").textContent = fmtQty(qty) + " pcs"; $("specId").textContent = genSpecId(); $("hfMaterial").textContent = material; const dimText = fmtDims(); $("viewportDims").textContent = dimText; } $("gradeSelect").addEventListener("change", render); $("platingSelect").addEventListener("change", render); $("heatSelect").addEventListener("change", render); $("qtySlider").addEventListener("input", render); // ---------- application surface (air gap / steel thickness) ---------- function clampSurface(raw, min, max){ if(isNaN(raw)) return min; return Math.min(max, Math.max(min, raw)); } $("idealSurfaceToggle").addEventListener("change", e => { idealSurface = e.target.checked; $("surfaceFields").style.display = idealSurface ? "none" : "block"; render(); }); $("airGapSlider").addEventListener("input", e => { airGapMm = parseFloat(e.target.value); $("airGapNumber").value = airGapMm; $("airGapVal").textContent = `${fmtMm(airGapMm)} mm`; render(); }); $("airGapNumber").addEventListener("input", () => { const raw = parseFloat($("airGapNumber").value); if(isNaN(raw)) return; const clamped = clampSurface(raw, 0, 5); airGapMm = clamped; $("airGapSlider").value = clamped; $("airGapVal").textContent = `${fmtMm(airGapMm)} mm`; render(); }); $("airGapNumber").addEventListener("blur", () => { const clamped = clampSurface(parseFloat($("airGapNumber").value), 0, 5); $("airGapNumber").value = clamped; airGapMm = clamped; $("airGapSlider").value = clamped; $("airGapVal").textContent = `${fmtMm(airGapMm)} mm`; render(); }); $("steelThickSlider").addEventListener("input", e => { steelThicknessMm = parseFloat(e.target.value); $("steelThickNumber").value = steelThicknessMm; $("steelThickVal").textContent = `${fmtMm(steelThicknessMm)} mm`; render(); }); $("steelThickNumber").addEventListener("input", () => { const raw = parseFloat($("steelThickNumber").value); if(isNaN(raw)) return; const clamped = clampSurface(raw, 0.2, 10); steelThicknessMm = clamped; $("steelThickSlider").value = clamped; $("steelThickVal").textContent = `${fmtMm(steelThicknessMm)} mm`; render(); }); $("steelThickNumber").addEventListener("blur", () => { const clamped = clampSurface(parseFloat($("steelThickNumber").value), 0.2, 10); $("steelThickNumber").value = clamped; steelThicknessMm = clamped; $("steelThickSlider").value = clamped; $("steelThickVal").textContent = `${fmtMm(steelThicknessMm)} mm`; render(); }); $("resetBtn").addEventListener("click", () => { shape = "disc"; dims = { diameter: 10, thickness: 5, outerDiameter: 10, innerDiameter: 5, length: 20, width: 10 }; unit = "mm"; material = "NdFeB"; tolerance = "standard"; idealSurface = true; airGapMm = 0; steelThicknessMm = 10; $("materialSelect").value = "NdFeB"; $("idealSurfaceToggle").checked = true; $("surfaceFields").style.display = "none"; $("airGapSlider").value = 0; $("airGapNumber").value = 0; $("airGapVal").textContent = "0 mm"; $("steelThickSlider").value = 10; $("steelThickNumber").value = 10; $("steelThickVal").textContent = "10 mm"; populateMaterialFields(true); populateMagnetizationOptions(true); populateToleranceOptions(); $("toleranceSelect").value = "standard"; $("qtySlider").value = "1"; setUnitBtns(); buildShapeGrid(); buildDimFields(); render(); }); // ---------- quote modal ---------- const overlay = $("modalOverlay"); $("openQuote").addEventListener("click", () => { $("formStage").style.display = "block"; $("sentStage").style.display = "none"; const mat = MATERIALS[material]; const gradeKey = $("gradeSelect").value; const grade = mat.grades[gradeKey] || Object.values(mat.grades)[0]; const plating = $("platingSelect").value; const platingLabel = (mat.plating.find(p => p.value === plating) || mat.plating[0]).label; const heatKey = $("heatSelect").value; const heat = mat.heatSeries[heatKey] || Object.values(mat.heatSeries)[0]; const magOptions = MAGNETIZATION[shape]; const magOption = magOptions[magnetization] || Object.values(magOptions)[0]; const qty = qtySteps[parseInt($("qtySlider").value,10)-1]; $("modalSpecBox").innerHTML = `${$("specId").textContent} — ${SHAPES[shape].label}, ${fmtDims()} (${fmtTolerance()})
Magnetized: ${magOption.label}
${mat.label}, grade ${grade.label}${Object.keys(mat.heatSeries).length > 1 ? " (" + heat.label + ")" : ""}, ${platingLabel}, max ${heat.maxTemp}°C
Qty: ${fmtQty(qty)} pcs · Est. pull: ${Math.round(computePullLbs()*10)/10} lbs`; overlay.classList.add("open"); }); $("closeModal").addEventListener("click", () => overlay.classList.remove("open")); overlay.addEventListener("click", e => { if(e.target === overlay) overlay.classList.remove("open"); }); $("doneBtn").addEventListener("click", () => overlay.classList.remove("open")); $("quoteForm").addEventListener("submit", function(e){ e.preventDefault(); const name = $("qName").value.trim(); const company = $("qCompany").value.trim(); const email = $("qEmail").value.trim(); const phone = $("qPhone").value.trim(); const notes = $("qNotes").value.trim(); const mat = MATERIALS[material]; const gradeKey = $("gradeSelect").value; const grade = mat.grades[gradeKey] || Object.values(mat.grades)[0]; const plating = $("platingSelect").value; const platingLabel = (mat.plating.find(p => p.value === plating) || mat.plating[0]).label; const heatKey = $("heatSelect").value; const heat = mat.heatSeries[heatKey] || Object.values(mat.heatSeries)[0]; const magOptions = MAGNETIZATION[shape]; const magOption = magOptions[magnetization] || Object.values(magOptions)[0]; const qty = qtySteps[parseInt($("qtySlider").value,10)-1]; const subject = `Quote request — ${$("specId").textContent} (${SHAPES[shape].label}, ${mat.label})`; const bodyLines = [ `Spec ID: ${$("specId").textContent}`, `Material: ${mat.label}`, `Shape: ${SHAPES[shape].label}`, `Dimensions: ${fmtDims()}`, `Magnetization direction: ${magOption.label}`, `Tolerance: ${fmtTolerance()} (${TOLERANCES[tolerance].label})`, `Grade: ${grade.label}`, `Heat series: ${Object.keys(mat.heatSeries).length > 1 ? heat.label : "n/a"} — max ${heat.maxTemp}°C`, `Coating: ${platingLabel}`, `Quantity: ${fmtQty(qty)} pcs`, `Estimated pull force: ${Math.round(computePullLbs()*10)/10} lbs`, ``, `Requested by: ${name}${company ? " — " + company : ""}`, `Email: ${email}`, phone ? `Phone: ${phone}` : null, notes ? `` : null, notes ? `Notes: ${notes}` : null ].filter(Boolean); const mailto = `mailto:${QUOTE_EMAIL}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(bodyLines.join("\n"))}`; // Log this to the tracker so the contact info and spec reach you even // if the visitor's email client never actually gets used to hit send. // Fire-and-forget: we don't await this before navigating, since mailto: // should open immediately regardless of whether logging succeeds. const design = serializeDesign(); logDesignEvent("quote", design, $("specId").textContent).then(designId => { logQuoteRequest(designId, { name, company, email, phone, notes }); }); window.location.href = mailto; $("formStage").style.display = "none"; $("sentStage").style.display = "block"; }); // ---------- init ---------- populateMaterialFields(true); populateMagnetizationOptions(true); populateToleranceOptions(); buildShapeGrid(); buildDimFields(); render(); renderSavedDesignsList(); loadDesignFromUrlIfPresent(); })();