// ─── Pure conversion engine ───────────────────────────────────────── // No DOM dependencies — just math. Used by the Calculator component. import type { CalculatorDef } from './data/calculators'; export interface SolveResult { val1: string; val2: string; val3: string; } function fmt(n: number): string { return parseFloat(n.toFixed(6)).toString(); } function gcd(a: number, b: number): number { a = Math.abs(Math.round(a)); b = Math.abs(Math.round(b)); return b ? gcd(b, a % b) : a; } /** * Run the conversion for a given calculator definition. * `source` indicates which field the user is typing in (1, 2, or 3). * Returns new string values for all fields. */ export function solve( calc: CalculatorDef, source: 1 | 2 | 3, rawVal1: string, rawVal2: string, rawVal3: string ): SolveResult { const v1 = parseFloat(rawVal1); const v2 = parseFloat(rawVal2); const v3 = parseFloat(rawVal3); const factor = calc.factor ?? 1; const offset = calc.offset ?? 0; let out: SolveResult = { val1: rawVal1, val2: rawVal2, val3: rawVal3 }; switch (calc.type) { case 'standard': if (source === 1) { out.val2 = !isNaN(v1) ? fmt(v1 * factor + offset) : ''; } else { out.val1 = !isNaN(v2) ? fmt((v2 - offset) / factor) : ''; } break; case 'inverse': if (source === 1) { out.val2 = (!isNaN(v1) && v1 !== 0) ? fmt(factor / v1) : ''; } else { out.val1 = (!isNaN(v2) && v2 !== 0) ? fmt(factor / v2) : ''; } break; case '3col': if (source === 1 || source === 2) { out.val3 = (!isNaN(v1) && !isNaN(v2) && v2 !== 0) ? fmt(v1 / v2) : ''; } else { out.val1 = (!isNaN(v3) && !isNaN(v2)) ? fmt(v3 * v2) : ''; } break; case '3col-mul': if (source === 1 || source === 2) { out.val3 = (!isNaN(v1) && !isNaN(v2)) ? fmt(v1 * v2) : ''; } else { out.val1 = (!isNaN(v3) && !isNaN(v2) && v2 !== 0) ? fmt(v3 / v2) : ''; } break; case 'base': { const fromBase = calc.fromBase ?? 10; const toBase = calc.toBase ?? 2; if (source === 1) { const val = rawVal1.trim(); if (!val) { out.val2 = ''; break; } const dec = parseInt(val, fromBase); out.val2 = !isNaN(dec) ? dec.toString(toBase).toUpperCase() : 'Invalid'; } else { const val = rawVal2.trim(); if (!val) { out.val1 = ''; break; } const dec = parseInt(val, toBase); out.val1 = !isNaN(dec) ? dec.toString(fromBase).toUpperCase() : 'Invalid'; } break; } case 'text-bin': if (source === 1) { out.val2 = rawVal1.split('').map(c => c.charCodeAt(0).toString(2).padStart(8, '0')).join(' '); } else { try { out.val1 = rawVal2.split(' ').map(b => String.fromCharCode(parseInt(b, 2))).join(''); } catch { out.val1 = 'Error'; } } break; case 'bin-text': if (source === 1) { try { out.val2 = rawVal1.split(' ').map(b => String.fromCharCode(parseInt(b, 2))).join(''); } catch { out.val2 = 'Error'; } } else { out.val1 = rawVal2.split('').map(c => c.charCodeAt(0).toString(2).padStart(8, '0')).join(' '); } break; case 'dms-dd': if (source === 1) { if (!isNaN(v1)) { const d = Math.floor(v1); const md = (v1 - d) * 60; const m = Math.floor(md); const sec = ((md - m) * 60).toFixed(2); out.val2 = `${d}° ${m}' ${sec}"`; } else { out.val2 = ''; } } else { const match = rawVal2.match(/(?:([0-9.-]+)\s*°)?\s*(?:([0-9.-]+)\s*')?\s*(?:([0-9.-]+)\s*")?/); if (match && rawVal2.trim().length > 0) { const d = parseFloat(match[1]) || 0; const m = parseFloat(match[2]) || 0; const sec = parseFloat(match[3]) || 0; out.val1 = fmt(d + m / 60 + sec / 3600); } else { out.val1 = ''; } } break; case 'dec-frac': { // Two calculators share this type: // - decimal -> fraction // - fraction -> decimal // Detect which direction the left field represents via its label. const fractionFirst = calc.labels.in1.toLowerCase().includes('fraction'); const decimalToFraction = (n: number) => { if (isNaN(n)) return ''; const parts = n.toString().split('.'); const len = parts[1] ? parts[1].length : 0; const den = Math.pow(10, len); const num = n * den; const div = gcd(num, den); return `${num / div}/${den / div}`; }; const fractionToDecimal = (raw: string) => { const parts = raw.split('/'); if (parts.length === 2 && !isNaN(Number(parts[0])) && !isNaN(Number(parts[1])) && Number(parts[1]) !== 0) { return fmt(Number(parts[0]) / Number(parts[1])); } const f = parseFloat(parts[0]); return !isNaN(f) ? f.toString() : ''; }; if (fractionFirst) { if (source === 1) { out.val2 = fractionToDecimal(rawVal1); } else { out.val1 = decimalToFraction(v2); } } else { if (source === 1) { out.val2 = decimalToFraction(v1); } else { out.val1 = fractionToDecimal(rawVal2); } } break; } case 'db-int': if (source === 1) { out.val2 = !isNaN(v1) ? (1e-12 * Math.pow(10, v1 / 10)).toExponential(6) : ''; } else { out.val1 = (!isNaN(v2) && v2 > 0) ? fmt(10 * Math.log10(v2 / 1e-12)) : ''; } break; case 'db-spl': if (source === 1) { out.val2 = !isNaN(v1) ? fmt(20 * Math.pow(10, v1 / 20)) : ''; } else { out.val1 = (!isNaN(v2) && v2 > 0) ? fmt(20 * Math.log10(v2 / 20)) : ''; } break; case 'db-v': if (source === 1) { out.val2 = !isNaN(v1) ? fmt(Math.pow(10, v1 / 20)) : ''; } else { out.val1 = (!isNaN(v2) && v2 > 0) ? fmt(20 * Math.log10(v2)) : ''; } break; case 'db-w': if (source === 1) { out.val2 = !isNaN(v1) ? fmt(Math.pow(10, v1 / 10)) : ''; } else { out.val1 = (!isNaN(v2) && v2 > 0) ? fmt(10 * Math.log10(v2)) : ''; } break; case 'awg': { const log92 = Math.log(92); const awgToDiameterMm = (g: number) => 0.127 * Math.pow(92, (36 - g) / 39); const diameterMmToAwg = (d: number) => 36 - 39 * Math.log(d / 0.127) / log92; const awgToCircularMils = (g: number) => 1000 * Math.pow(92, (36 - g) / 19.5); const circularMilsToAwg = (a: number) => 36 - 19.5 * Math.log(a / 1000) / log92; const awgToAreaMm2 = (g: number) => { const d = awgToDiameterMm(g); return Math.PI * Math.pow(d, 2) / 4; }; const areaMm2ToAwg = (a: number) => { const d = Math.sqrt((4 * a) / Math.PI); return diameterMmToAwg(d); }; const slug = calc.slug; const formatAwg = (g: number) => isFinite(g) ? fmt(g) : ''; const awgIsInput = calc.labels.in1.toLowerCase().includes('awg'); const isCircular = slug.includes('circular-mils'); const isArea = slug.includes('square-millimeters'); if (isCircular) { if (awgIsInput) { if (source === 1) out.val2 = !isNaN(v1) ? fmt(awgToCircularMils(v1)) : ''; else out.val1 = (!isNaN(v2) && v2 > 0) ? formatAwg(circularMilsToAwg(v2)) : ''; } else { if (source === 1) out.val2 = (!isNaN(v1) && v1 > 0) ? formatAwg(circularMilsToAwg(v1)) : ''; else out.val1 = !isNaN(v2) ? fmt(awgToCircularMils(v2)) : ''; } } else if (isArea) { if (awgIsInput) { if (source === 1) out.val2 = !isNaN(v1) ? fmt(awgToAreaMm2(v1)) : ''; else out.val1 = (!isNaN(v2) && v2 > 0) ? formatAwg(areaMm2ToAwg(v2)) : ''; } else { if (source === 1) out.val2 = (!isNaN(v1) && v1 > 0) ? formatAwg(areaMm2ToAwg(v1)) : ''; else out.val1 = !isNaN(v2) ? fmt(awgToAreaMm2(v2)) : ''; } } else { // diameter in millimeters if (awgIsInput) { if (source === 1) out.val2 = !isNaN(v1) ? fmt(awgToDiameterMm(v1)) : ''; else out.val1 = (!isNaN(v2) && v2 > 0) ? formatAwg(diameterMmToAwg(v2)) : ''; } else { if (source === 1) out.val2 = (!isNaN(v1) && v1 > 0) ? formatAwg(diameterMmToAwg(v1)) : ''; else out.val1 = !isNaN(v2) ? fmt(awgToDiameterMm(v2)) : ''; } } break; } case 'awg-swg': { const log92 = Math.log(92); const awgToDiameterMm = (g: number) => 0.127 * Math.pow(92, (36 - g) / 39); const diameterMmToAwg = (d: number) => 36 - 39 * Math.log(d / 0.127) / log92; const swgTable: Record = { 0: 8.23, 1: 7.62, 2: 7.01, 3: 6.4, 4: 5.89, 5: 5.385, 6: 4.877, 7: 4.47, 8: 4.064, 9: 3.658, 10: 3.251, 11: 2.946, 12: 2.642, 13: 2.337, 14: 2.032, 15: 1.829, 16: 1.626, 17: 1.422, 18: 1.219, 19: 1.016, 20: 0.914, 21: 0.813, 22: 0.711, 23: 0.61, 24: 0.559, 25: 0.508, 26: 0.457, 27: 0.417, 28: 0.376, 29: 0.345, 30: 0.315, 31: 0.294, 32: 0.274, 33: 0.254, 34: 0.234, 35: 0.213, 36: 0.193, 37: 0.173, 38: 0.152, 39: 0.132, 40: 0.122, 41: 0.112, 42: 0.102, 43: 0.091, 44: 0.081, 45: 0.071, 46: 0.061, 47: 0.051, 48: 0.04, 49: 0.03, 50: 0.025 }; const nearestSwg = (diamMm: number) => { let bestGauge = 0; let bestDiff = Number.POSITIVE_INFINITY; for (const [gStr, d] of Object.entries(swgTable)) { const diff = Math.abs(diamMm - d); if (diff < bestDiff) { bestDiff = diff; bestGauge = Number(gStr); } } return bestGauge; }; if (source === 1) { // AWG -> SWG if (!isNaN(v1)) { const diam = awgToDiameterMm(v1); out.val2 = fmt(nearestSwg(diam)); } else { out.val2 = ''; } } else { // SWG -> AWG if (!isNaN(v2)) { const diam = swgTable[Math.round(v2)]; if (diam) out.val1 = fmt(diameterMmToAwg(diam)); else out.val1 = ''; } else { out.val1 = ''; } } break; } case 'ev-lux': { const isEvFirst = calc.labels.in1.toLowerCase().includes('ev'); const toLux = (ev: number) => 2.5 * Math.pow(2, ev); const toEv = (lux: number) => lux > 0 ? Math.log(lux / 2.5) / Math.log(2) : NaN; if (isEvFirst) { if (source === 1) out.val2 = !isNaN(v1) ? fmt(toLux(v1)) : ''; else out.val1 = (!isNaN(v2) && v2 > 0) ? fmt(toEv(v2)) : ''; } else { if (source === 1) out.val2 = (!isNaN(v1) && v1 > 0) ? fmt(toEv(v1)) : ''; else out.val1 = !isNaN(v2) ? fmt(toLux(v2)) : ''; } break; } case 'aov': { const sensorWidth = 36; // mm, full-frame horizontal const isFocalFirst = calc.labels.in1.toLowerCase().includes('focal'); const toAov = (f: number) => f > 0 ? (2 * Math.atan(sensorWidth / (2 * f)) * 180 / Math.PI) : NaN; const toFocal = (angle: number) => { const radians = angle * Math.PI / 180; return Math.tan(radians / 2) !== 0 ? sensorWidth / (2 * Math.tan(radians / 2)) : NaN; }; if (isFocalFirst) { if (source === 1) out.val2 = (!isNaN(v1) && v1 !== 0) ? fmt(toAov(v1)) : ''; else out.val1 = (!isNaN(v2) && v2 !== 0) ? fmt(toFocal(v2)) : ''; } else { if (source === 1) out.val2 = (!isNaN(v1) && v1 !== 0) ? fmt(toFocal(v1)) : ''; else out.val1 = (!isNaN(v2) && v2 !== 0) ? fmt(toAov(v2)) : ''; } break; } case 'brinell-rockwell': { // Approximate correlation for steels: // BHN = (1520000 - 4500 * HRC) / (100 - HRC)^2 if (source === 1) { // Brinell to Rockwell C if (!isNaN(v1) && v1 > 0) { const a = v1; const disc = 4500 ** 2 + 4 * a * 1070000; const y = (4500 + Math.sqrt(disc)) / (2 * a); const hrc = 100 - y; out.val2 = fmt(hrc); } else { out.val2 = ''; } } else { // Rockwell C to Brinell if (!isNaN(v2) && v2 < 100) { const h = v2; const bhn = (1520000 - 4500 * h) / Math.pow(100 - h, 2); out.val1 = fmt(bhn); } else { out.val1 = ''; } } break; } case 'molarity': { const m = v1; // mol/L const gpl = v2; // grams/L const molarMass = v3; // g/mol if (source === 1) { out.val2 = (!isNaN(m) && !isNaN(molarMass)) ? fmt(m * molarMass) : ''; } else if (source === 2) { out.val1 = (!isNaN(gpl) && !isNaN(molarMass) && molarMass !== 0) ? fmt(gpl / molarMass) : ''; } else { if (!isNaN(m) && !isNaN(molarMass)) out.val2 = fmt(m * molarMass); else if (!isNaN(gpl) && !isNaN(molarMass) && molarMass !== 0) out.val1 = fmt(gpl / molarMass); } break; } case 'rockwell-vickers': { const hrcToBhn = (h: number) => (1520000 - 4500 * h) / Math.pow(100 - h, 2); const bhnToHrc = (b: number) => { const disc = 4500 ** 2 + 4 * b * 1070000; const y = (4500 + Math.sqrt(disc)) / (2 * b); return 100 - y; }; const bhnToHv = (b: number) => b * 0.95; const hvToBhn = (hv: number) => hv / 0.95; if (source === 1) { const hrc = v1; if (!isNaN(hrc) && hrc < 100) { const hv = bhnToHv(hrcToBhn(hrc)); out.val2 = fmt(hv); } else out.val2 = ''; } else { const hv = v2; if (!isNaN(hv) && hv > 0) { const bhn = hvToBhn(hv); out.val1 = fmt(bhnToHrc(bhn)); } else out.val1 = ''; } break; } case 'sus-cst': { const susToCst = (sus: number) => { if (sus <= 0) return NaN; if (sus < 100) return 0.226 * sus - 195 / sus; return 0.22 * sus - 135 / sus; }; const cstToSus = (cst: number) => { if (cst <= 0) return NaN; const low = (cst + Math.sqrt(cst * cst + 4 * 0.226 * 195)) / (2 * 0.226); const high = (cst + Math.sqrt(cst * cst + 4 * 0.22 * 135)) / (2 * 0.22); return low < 100 ? low : high; }; if (source === 1) { out.val2 = !isNaN(v1) ? fmt(susToCst(v1)) : ''; } else { out.val1 = !isNaN(v2) ? fmt(cstToSus(v2)) : ''; } break; } case 'swg': { const swgTable: Record = { 0: 8.23, 1: 7.62, 2: 7.01, 3: 6.4, 4: 5.89, 5: 5.38, 6: 4.88, 7: 4.47, 8: 4.06, 9: 3.66, 10: 3.25, 11: 2.95, 12: 2.64, 13: 2.34, 14: 2.03, 15: 1.83, 16: 1.63, 17: 1.42, 18: 1.22, 19: 1.02, 20: 0.91, 21: 0.81, 22: 0.71, 23: 0.61, 24: 0.56, 25: 0.51, 26: 0.46, 27: 0.42, 28: 0.38, 29: 0.35, 30: 0.32, 31: 0.29, 32: 0.27, 33: 0.25, 34: 0.23, 35: 0.21, 36: 0.19, 37: 0.17, 38: 0.15, 39: 0.14, 40: 0.12, 41: 0.11, 42: 0.1, 43: 0.09, 44: 0.08, 45: 0.07, 46: 0.064, 47: 0.058, 48: 0.051, 49: 0.045, 50: 0.04 }; const gaugeToMm = (g: number) => swgTable[Math.round(g)]; const mmToGauge = (mm: number) => { let best = -1, bestDiff = Infinity; for (const [gStr, diam] of Object.entries(swgTable)) { const diff = Math.abs(mm - diam); if (diff < bestDiff) { bestDiff = diff; best = parseInt(gStr, 10); } } return best; }; if (calc.labels.in1.toLowerCase().includes('swg')) { if (source === 1) out.val2 = !isNaN(v1) ? fmt(gaugeToMm(v1) ?? NaN) : ''; else out.val1 = (!isNaN(v2) && v2 > 0) ? fmt(mmToGauge(v2)) : ''; } else { if (source === 1) out.val2 = (!isNaN(v1) && v1 > 0) ? fmt(mmToGauge(v1)) : ''; else out.val1 = !isNaN(v2) ? fmt(gaugeToMm(v2) ?? NaN) : ''; } break; } } return out; }