455 lines
16 KiB
TypeScript
455 lines
16 KiB
TypeScript
// ─── 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<number, number> = {
|
|
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<number, number> = {
|
|
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;
|
|
}
|