mirror of
https://github.com/Horizontal42/summarum.git
synced 2026-11-10 16:51:59 +03:00
- Fix race condition in write_atomic_bytes by using unique temp file names - Fix logic flaw in DataDirGate authorization on migration failures - Reject invalid padding in base64_decode - Set size limits on json fetches for rates - Fix Date math around DST boundaries for tomorrow/yesterday - Handle division by zero exceptions gracefully in goal seek - Fix OOM vector for huge hexadecimal number rendering
155 lines
5.8 KiB
TypeScript
155 lines
5.8 KiB
TypeScript
// Turns values into display strings: unit/currency formats plus
|
|
// precision and grouping settings.
|
|
import { Decimal, DateVal, EngineSettings, Value } from "./types";
|
|
|
|
// Intl.DateTimeFormat construction is relatively expensive and every line with
|
|
// a date rebuilds one on each render — cache by locale+options instead.
|
|
const dtfCache = new Map<string, Intl.DateTimeFormat>();
|
|
function dtf(locale: string | undefined, opts: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {
|
|
const key = (locale ?? "") + JSON.stringify(opts);
|
|
let f = dtfCache.get(key);
|
|
if (!f) {
|
|
f = new Intl.DateTimeFormat(locale, opts);
|
|
dtfCache.set(key, f);
|
|
}
|
|
return f;
|
|
}
|
|
|
|
/** Fixed-layout date display (iso/dmy/mdy), independent of OS locale. */
|
|
function formatDateFixed(v: DateVal, fmt: "iso" | "dmy" | "mdy"): string {
|
|
const parts = dtf("en-GB", {
|
|
timeZone: v.timeZone, year: "numeric", month: "2-digit", day: "2-digit",
|
|
hour: "2-digit", minute: "2-digit", hourCycle: "h23",
|
|
}).formatToParts(v.ms);
|
|
const get = (t: Intl.DateTimeFormatPartTypes) => parts.find((p) => p.type === t)?.value ?? "";
|
|
const [y, mo, d] = [get("year"), get("month"), get("day")];
|
|
const date = fmt === "iso" ? `${y}-${mo}-${d}` : fmt === "dmy" ? `${d}.${mo}.${y}` : `${mo}/${d}/${y}`;
|
|
return v.hasTime ? `${date} ${get("hour")}:${get("minute")}` : date;
|
|
}
|
|
|
|
function group(intPart: string, sep: string): string {
|
|
if (!sep) return intPart;
|
|
const neg = intPart.startsWith("-");
|
|
const digits = neg ? intPart.slice(1) : intPart;
|
|
if (digits.length < 4) return intPart;
|
|
const grouped = digits.replace(/\B(?=(\d{3})+(?!\d))/g, sep);
|
|
return (neg ? "-" : "") + grouped;
|
|
}
|
|
|
|
export function formatNumber(v: Decimal, s: EngineSettings): string {
|
|
let rounded = v.toDecimalPlaces(s.precision);
|
|
if (rounded.isZero() && !v.isZero() && v.abs().gte("1e-12")) {
|
|
// 1 mm in km must not show "0 km" — fall back to significant digits
|
|
// (anything below 1e-12 is conversion noise, e.g. 32 F in C)
|
|
rounded = v.toSignificantDigits(Math.max(s.precision, 1));
|
|
}
|
|
const str = rounded.toString();
|
|
if (str.includes("e") || str.includes("E")) return str;
|
|
const [int, frac] = str.split(".");
|
|
const intG = group(int, s.groupSeparator);
|
|
return frac !== undefined ? intG + s.decimalSeparator + frac : intG;
|
|
}
|
|
|
|
export function formatValue(v: Value, s: EngineSettings): string {
|
|
if (v.kind === "chart") return "";
|
|
|
|
if (v.kind === "percent") {
|
|
return formatNumber(v.value, s) + "%";
|
|
}
|
|
|
|
if (v.kind === "date") {
|
|
const opts: Intl.DateTimeFormatOptions = { timeZone: v.timeZone };
|
|
if (v.timeOnly) {
|
|
return dtf(undefined, { ...opts, hour: "2-digit", minute: "2-digit", hour12: false }).format(v.ms);
|
|
}
|
|
if (s.dateFormat !== "system") return formatDateFixed(v, s.dateFormat);
|
|
if (v.hasTime) {
|
|
return dtf(undefined, {
|
|
...opts, hour: "2-digit", minute: "2-digit", hour12: false,
|
|
day: "numeric", month: "short", year: "numeric",
|
|
}).format(v.ms);
|
|
}
|
|
return dtf(undefined, { ...opts, day: "numeric", month: "long", year: "numeric" }).format(v.ms);
|
|
}
|
|
|
|
// quantity
|
|
if (v.repr === "hex" || v.repr === "binary" || v.repr === "octal") {
|
|
if (v.value.abs().gte(new Decimal("1e10000"))) {
|
|
return formatNumber(v.value, s);
|
|
}
|
|
const int = v.value.toDecimalPlaces(0, Decimal.ROUND_HALF_UP);
|
|
const neg = int.isNegative();
|
|
const abs = int.abs();
|
|
const prefix = v.repr === "hex" ? "0x" : v.repr === "binary" ? "0b" : "0o";
|
|
const radix = v.repr === "hex" ? 16 : v.repr === "binary" ? 2 : 8;
|
|
const digits = BigInt(abs.toFixed(0)).toString(radix);
|
|
return (neg ? "-" : "") + prefix + (v.repr === "hex" ? digits.toUpperCase() : digits);
|
|
}
|
|
if (v.repr === "scientific") {
|
|
return v.value.toExponential(Math.min(s.precision, 10)).replace(/e\+?/, "e");
|
|
}
|
|
if (v.repr === "plain") {
|
|
return v.value.toString(); // no grouping (unix timestamps)
|
|
}
|
|
if (v.repr === "fraction") {
|
|
return formatFraction(v.value);
|
|
}
|
|
if (v.repr === "roman") {
|
|
return formatRoman(v.value) ?? formatNumber(v.value, s);
|
|
}
|
|
|
|
const num = formatNumber(v.value, s);
|
|
if (!v.unit) return num;
|
|
return withUnit(num, v.unit.format);
|
|
}
|
|
|
|
function withUnit(num: string, f: string): string {
|
|
if (f.includes("{}")) {
|
|
return f.replace("{}", num); // currency template
|
|
}
|
|
// suffix units: letters get a space ("12.7 cm"), symbols attach ("5″", "25°C")
|
|
return /^[\p{L}]/u.test(f) && !f.startsWith("°") ? `${num} ${f}` : `${num}${f}`;
|
|
}
|
|
|
|
/** Best rational approximation via continued fractions: 0.75 -> "3/4". */
|
|
// Native float, not Decimal: the continued-fraction algorithm is tuned to
|
|
// float epsilon by design, and this is display formatting, not arithmetic
|
|
// carried forward into the result.
|
|
function formatFraction(v: Decimal): string {
|
|
const neg = v.isNegative();
|
|
const x = v.abs().toNumber();
|
|
if (!Number.isFinite(x)) return v.toString();
|
|
let h1 = 1, h0 = 0, k1 = 0, k0 = 1, b = x;
|
|
for (let i = 0; i < 40; i++) {
|
|
const a = Math.floor(b);
|
|
[h1, h0] = [a * h1 + h0, h1];
|
|
[k1, k0] = [a * k1 + k0, k1];
|
|
if (k1 > 10000) break;
|
|
if (Math.abs(x - h1 / k1) < 1e-12) break;
|
|
const frac = b - a;
|
|
if (frac < 1e-12) break;
|
|
b = 1 / frac;
|
|
}
|
|
const sign = neg ? "-" : "";
|
|
if (k1 === 1) return sign + String(h1);
|
|
const whole = Math.floor(h1 / k1);
|
|
const rem = h1 - whole * k1;
|
|
return whole > 0 ? `${sign}${whole} ${rem}/${k1}` : `${sign}${rem}/${k1}`;
|
|
}
|
|
|
|
function formatRoman(v: Decimal): string | null {
|
|
if (!v.isInteger() || v.lt(1) || v.gt(3999)) return null;
|
|
let n = v.toNumber();
|
|
const table: Array<[number, string]> = [
|
|
[1000, "M"], [900, "CM"], [500, "D"], [400, "CD"], [100, "C"], [90, "XC"],
|
|
[50, "L"], [40, "XL"], [10, "X"], [9, "IX"], [5, "V"], [4, "IV"], [1, "I"],
|
|
];
|
|
let out = "";
|
|
for (const [val, sym] of table) {
|
|
while (n >= val) {
|
|
out += sym;
|
|
n -= val;
|
|
}
|
|
}
|
|
return out;
|
|
}
|