191 lines
8.7 KiB
JavaScript
191 lines
8.7 KiB
JavaScript
.pragma library
|
|
|
|
// Unit conversion. Linear units map to a base via { dim, f }; temperature
|
|
// uses explicit to/from-base helpers to handle the offset.
|
|
const TABLE = {
|
|
// length (base: meter)
|
|
"m": { dim: "len", f: 1 }, "meter": { dim: "len", f: 1 }, "meters": { dim: "len", f: 1 },
|
|
"km": { dim: "len", f: 1000 }, "cm": { dim: "len", f: 0.01 },
|
|
"mm": { dim: "len", f: 0.001 }, "um": { dim: "len", f: 1e-6 }, "nm": { dim: "len", f: 1e-9 },
|
|
"in": { dim: "len", f: 0.0254 }, "inch": { dim: "len", f: 0.0254 }, "inches": { dim: "len", f: 0.0254 },
|
|
"ft": { dim: "len", f: 0.3048 }, "foot": { dim: "len", f: 0.3048 }, "feet": { dim: "len", f: 0.3048 },
|
|
"yd": { dim: "len", f: 0.9144 }, "yard": { dim: "len", f: 0.9144 }, "yards": { dim: "len", f: 0.9144 },
|
|
"mi": { dim: "len", f: 1609.344 }, "mile": { dim: "len", f: 1609.344 }, "miles": { dim: "len", f: 1609.344 },
|
|
"nmi": { dim: "len", f: 1852 },
|
|
// mass (base: gram)
|
|
"g": { dim: "mass", f: 1 }, "gram": { dim: "mass", f: 1 }, "grams": { dim: "mass", f: 1 },
|
|
"kg": { dim: "mass", f: 1000 }, "mg": { dim: "mass", f: 0.001 },
|
|
"lb": { dim: "mass", f: 453.59237 }, "lbs": { dim: "mass", f: 453.59237 },
|
|
"oz": { dim: "mass", f: 28.349523125 },
|
|
"ton": { dim: "mass", f: 1e6 }, "tonne": { dim: "mass", f: 1e6 }, "t": { dim: "mass", f: 1e6 },
|
|
// volume (base: liter)
|
|
"l": { dim: "vol", f: 1 }, "liter": { dim: "vol", f: 1 }, "liters": { dim: "vol", f: 1 },
|
|
"ml": { dim: "vol", f: 0.001 }, "cl": { dim: "vol", f: 0.01 },
|
|
"gal": { dim: "vol", f: 3.785411784 }, "gallon": { dim: "vol", f: 3.785411784 },
|
|
"qt": { dim: "vol", f: 0.946352946 }, "pt": { dim: "vol", f: 0.473176473 },
|
|
"cup": { dim: "vol", f: 0.2365882365 }, "cups": { dim: "vol", f: 0.2365882365 },
|
|
"floz": { dim: "vol", f: 0.0295735295625 }, "tbsp": { dim: "vol", f: 0.01478676478125 },
|
|
"tsp": { dim: "vol", f: 0.00492892159375 },
|
|
// time (base: second)
|
|
"s": { dim: "time", f: 1 }, "sec": { dim: "time", f: 1 }, "secs": { dim: "time", f: 1 },
|
|
"ms": { dim: "time", f: 0.001 }, "us": { dim: "time", f: 1e-6 },
|
|
"min": { dim: "time", f: 60 }, "mins": { dim: "time", f: 60 },
|
|
"h": { dim: "time", f: 3600 }, "hr": { dim: "time", f: 3600 }, "hrs": { dim: "time", f: 3600 },
|
|
"hour": { dim: "time", f: 3600 }, "hours": { dim: "time", f: 3600 },
|
|
"day": { dim: "time", f: 86400 }, "days": { dim: "time", f: 86400 },
|
|
"week": { dim: "time", f: 604800 }, "weeks": { dim: "time", f: 604800 },
|
|
"year": { dim: "time", f: 31557600 }, "years": { dim: "time", f: 31557600 },
|
|
// data (base: byte; SI and binary)
|
|
"b": { dim: "data", f: 1 }, "byte": { dim: "data", f: 1 }, "bytes": { dim: "data", f: 1 },
|
|
"kb": { dim: "data", f: 1e3 }, "mb": { dim: "data", f: 1e6 },
|
|
"gb": { dim: "data", f: 1e9 }, "tb": { dim: "data", f: 1e12 }, "pb": { dim: "data", f: 1e15 },
|
|
"kib": { dim: "data", f: 1024 }, "mib": { dim: "data", f: 1048576 },
|
|
"gib": { dim: "data", f: 1073741824 }, "tib": { dim: "data", f: 1099511627776 },
|
|
"bit": { dim: "data", f: 0.125 }, "bits": { dim: "data", f: 0.125 },
|
|
// speed (base: m/s)
|
|
"mps": { dim: "speed", f: 1 },
|
|
"kph": { dim: "speed", f: 1 / 3.6 }, "kmh": { dim: "speed", f: 1 / 3.6 },
|
|
"mph": { dim: "speed", f: 0.44704 },
|
|
"knot": { dim: "speed", f: 0.514444 }, "knots": { dim: "speed", f: 0.514444 },
|
|
// angle (base: radian)
|
|
"rad": { dim: "angle", f: 1 }, "deg": { dim: "angle", f: Math.PI / 180 },
|
|
"turn": { dim: "angle", f: 2 * Math.PI }, "grad": { dim: "angle", f: Math.PI / 200 },
|
|
// temperature (offset; handled specially)
|
|
"c": { dim: "temp" }, "celsius": { dim: "temp" },
|
|
"f": { dim: "temp" }, "fahrenheit": { dim: "temp" },
|
|
"k": { dim: "temp" }, "kelvin": { dim: "temp" }
|
|
};
|
|
|
|
function tempToK(v, u) {
|
|
if (u === "c" || u === "celsius") return v + 273.15;
|
|
if (u === "f" || u === "fahrenheit") return (v - 32) * 5 / 9 + 273.15;
|
|
return v;
|
|
}
|
|
function tempFromK(v, u) {
|
|
if (u === "c" || u === "celsius") return v - 273.15;
|
|
if (u === "f" || u === "fahrenheit") return (v - 273.15) * 9 / 5 + 32;
|
|
return v;
|
|
}
|
|
|
|
// ISO codes Frankfurter supports. Used to detect currency queries before we
|
|
// have rates cached (rates are fetched lazily and cached per-day).
|
|
const CURRENCIES = ["AUD","BGN","BRL","CAD","CHF","CNY","CZK","DKK","EUR","GBP",
|
|
"HKD","HUF","IDR","ILS","INR","ISK","JPY","KRW","MXN","NOK",
|
|
"NZD","PHP","PLN","RON","SEK","SGD","THB","TRY","USD","ZAR"];
|
|
const CURRENCY_SET = (function() { const s = {}; for (const c of CURRENCIES) s[c] = 1; return s; })();
|
|
|
|
function isCurrency(code) {
|
|
return !!CURRENCY_SET[code.toUpperCase()];
|
|
}
|
|
|
|
// Parse the "<num> <from> to[ <partial-to>]" shape used by both unit and
|
|
// currency conversion. Returns null if it doesn't match.
|
|
function parseQuery(expr) {
|
|
const m = expr.match(/^(-?\d+(?:\.\d+)?(?:e-?\d+)?)\s*([a-zA-Z]+)\s+(?:to|in|->)(?:\s+([a-zA-Z]*))?$/i);
|
|
if (!m) return null;
|
|
return {
|
|
amount: parseFloat(m[1]),
|
|
from: m[2].toLowerCase(),
|
|
partial: (m[3] || "").toLowerCase()
|
|
};
|
|
}
|
|
|
|
function applyConversion(v, fu, tu) {
|
|
const fd = TABLE[fu], td = TABLE[tu];
|
|
if (fd.dim === "temp") return tempFromK(tempToK(v, fu), tu);
|
|
return v * fd.f / td.f;
|
|
}
|
|
|
|
// Parse "<number> <from> to|in|-> <to>" and return { value, from, to } or null.
|
|
function convert(expr) {
|
|
const m = expr.match(/^(-?\d+(?:\.\d+)?(?:e-?\d+)?)\s*([a-zA-Z]+)\s+(?:to|in|->)\s+([a-zA-Z]+)$/i);
|
|
if (!m) return null;
|
|
const v = parseFloat(m[1]);
|
|
const fu = m[2].toLowerCase();
|
|
const tu = m[3].toLowerCase();
|
|
const fd = TABLE[fu], td = TABLE[tu];
|
|
if (!fd || !td || fd.dim !== td.dim) return null;
|
|
return { value: applyConversion(v, fu, tu), from: fu, to: tu };
|
|
}
|
|
|
|
// Build, per dimension, the list of canonical unit names — deduped so e.g.
|
|
// "m"/"meter"/"meters" don't all appear; prefer the shortest alias.
|
|
const CANONICAL_BY_DIM = (function() {
|
|
const byDim = {};
|
|
const tempCanon = { "c": 1, "f": 1, "k": 1 };
|
|
for (const name in TABLE) {
|
|
const td = TABLE[name];
|
|
if (!byDim[td.dim]) byDim[td.dim] = {};
|
|
const bucket = byDim[td.dim];
|
|
const key = td.dim === "temp" ? name : String(td.f);
|
|
if (td.dim === "temp" && !tempCanon[name]) continue;
|
|
if (bucket[key] === undefined || name.length < bucket[key].length) {
|
|
bucket[key] = name;
|
|
}
|
|
}
|
|
const out = {};
|
|
for (const dim in byDim) out[dim] = Object.values(byDim[dim]);
|
|
return out;
|
|
})();
|
|
|
|
// Given a fetched-rate table { TGT: rate, ... } where amount-of-base * rate =
|
|
// amount-of-target, return ordered { value, from, to } entries filtered by
|
|
// `partial`. Mirrors suggest() for units.
|
|
function currencyConvert(amount, base, partial, rates) {
|
|
const baseU = base.toUpperCase();
|
|
const candidates = Object.keys(rates).filter(c => c !== baseU);
|
|
let ordered;
|
|
if (!partial) {
|
|
ordered = candidates.slice().sort();
|
|
} else {
|
|
const p = partial.toUpperCase();
|
|
const scored = [];
|
|
for (const c of candidates) {
|
|
const s = scoreFuzzy(p, c);
|
|
if (s > 0) scored.push({ c, s });
|
|
}
|
|
scored.sort((a, b) => b.s - a.s);
|
|
ordered = scored.map(x => x.c);
|
|
}
|
|
return ordered.map(c => ({ value: amount * rates[c], from: baseU, to: c }));
|
|
}
|
|
|
|
function scoreFuzzy(q, name) {
|
|
const idx = name.indexOf(q);
|
|
if (idx === 0) return 1000 - name.length;
|
|
if (idx > 0) return 500 - name.length;
|
|
let qi = 0;
|
|
for (let i = 0; i < name.length && qi < q.length; i++) {
|
|
if (name[i] === q[qi]) qi++;
|
|
}
|
|
return qi === q.length ? 100 - name.length : 0;
|
|
}
|
|
|
|
// Parse "<number> <from> to|in|->[ <partial>]" and return an array of
|
|
// { value, from, to } for every unit in <from>'s dimension that fuzzy-matches
|
|
// <partial> (or all of them if <partial> is empty). Returns null if the input
|
|
// doesn't fit the "awaiting target unit" shape.
|
|
function suggest(expr) {
|
|
const m = expr.match(/^(-?\d+(?:\.\d+)?(?:e-?\d+)?)\s*([a-zA-Z]+)\s+(?:to|in|->)(?:\s+([a-zA-Z]*))?$/i);
|
|
if (!m) return null;
|
|
const v = parseFloat(m[1]);
|
|
const fu = m[2].toLowerCase();
|
|
const partial = (m[3] || "").toLowerCase();
|
|
const fd = TABLE[fu];
|
|
if (!fd) return null;
|
|
const candidates = (CANONICAL_BY_DIM[fd.dim] || []).filter(u => u !== fu);
|
|
let ordered;
|
|
if (!partial) {
|
|
ordered = candidates.slice().sort();
|
|
} else {
|
|
const scored = [];
|
|
for (const u of candidates) {
|
|
const s = scoreFuzzy(partial, u);
|
|
if (s > 0) scored.push({ u, s });
|
|
}
|
|
scored.sort((a, b) => b.s - a.s);
|
|
ordered = scored.map(x => x.u);
|
|
}
|
|
return ordered.map(tu => ({ value: applyConversion(v, fu, tu), from: fu, to: tu }));
|
|
}
|