summaryrefslogtreecommitdiff
path: root/apps/web/src/utils/math.ts
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/utils/math.ts')
-rw-r--r--apps/web/src/utils/math.ts391
1 files changed, 276 insertions, 115 deletions
diff --git a/apps/web/src/utils/math.ts b/apps/web/src/utils/math.ts
index 8043871f..45196b02 100644
--- a/apps/web/src/utils/math.ts
+++ b/apps/web/src/utils/math.ts
@@ -1,115 +1,276 @@
-export function clamp({
- value,
- min,
- max,
-}: {
- value: number;
- min: number;
- max: number;
-}): number {
- return Math.max(min, Math.min(max, value));
-}
-
-export function clampRound({
- value,
- min,
- max,
-}: {
- value: number;
- min: number;
- max: number;
-}): number {
- return Math.round(clamp({ value, min, max }));
-}
-
-export function getFractionDigitsForStep({ step }: { step: number }): number {
- const normalizedStep = step.toString().toLowerCase();
- if (normalizedStep.includes("e-")) {
- return Number(normalizedStep.split("e-")[1] ?? 0);
- }
- const [, fractionalPart = ""] = normalizedStep.split(".");
- return fractionalPart.length;
-}
-
-export function snapToStep({
- value,
- step,
-}: {
- value: number;
- step: number;
-}): number {
- if (step <= 0) return value;
- const snappedValue = Math.round(value / step) * step;
- return Number(
- snappedValue.toFixed(getFractionDigitsForStep({ step })),
- );
-}
-
-export function isNearlyEqual({
- leftValue,
- rightValue,
- epsilon = 0.0001,
-}: {
- leftValue: number;
- rightValue: number;
- epsilon?: number;
-}): boolean {
- return Math.abs(leftValue - rightValue) <= epsilon;
-}
-
-export function formatNumberForDisplay({
- value,
- fractionDigits,
- minFractionDigits = 0,
- maxFractionDigits = 6,
-}: {
- value: number;
- fractionDigits?: number;
- minFractionDigits?: number;
- maxFractionDigits?: number;
-}): string {
- const resolvedMaxFractionDigits = Math.max(
- 0,
- fractionDigits ?? maxFractionDigits,
- );
- const resolvedMinFractionDigits = Math.min(
- Math.max(0, fractionDigits ?? minFractionDigits),
- resolvedMaxFractionDigits,
- );
- const fixedValue = value.toFixed(resolvedMaxFractionDigits);
-
- if (resolvedMaxFractionDigits === 0) {
- return Number(fixedValue) === 0 ? "0" : fixedValue;
- }
-
- const [integerPart, fractionPart = ""] = fixedValue.split(".");
- const normalizedIntegerPart = Number(fixedValue) === 0 ? "0" : integerPart;
- let trimmedFractionPart = fractionPart;
-
- while (
- trimmedFractionPart.length > resolvedMinFractionDigits &&
- trimmedFractionPart.endsWith("0")
- ) {
- trimmedFractionPart = trimmedFractionPart.slice(0, -1);
- }
-
- return trimmedFractionPart
- ? `${normalizedIntegerPart}.${trimmedFractionPart}`
- : normalizedIntegerPart;
-}
-
-export function evaluateMathExpression({
- input,
-}: {
- input: string;
-}): number | null {
- const sanitized = input.trim();
- if (!/^[\d.\s+\-*/()]+$/.test(sanitized)) return null;
- try {
- const result = new Function(`return (${sanitized})`)();
- if (typeof result !== "number" || !Number.isFinite(result)) return null;
- return result;
- } catch {
- return null;
- }
-}
+export function clamp({
+ value,
+ min,
+ max,
+}: {
+ value: number;
+ min: number;
+ max: number;
+}): number {
+ return Math.max(min, Math.min(max, value));
+}
+
+export function clampRound({
+ value,
+ min,
+ max,
+}: {
+ value: number;
+ min: number;
+ max: number;
+}): number {
+ return Math.round(clamp({ value, min, max }));
+}
+
+export function getFractionDigitsForStep({ step }: { step: number }): number {
+ const normalizedStep = step.toString().toLowerCase();
+ if (normalizedStep.includes("e-")) {
+ return Number(normalizedStep.split("e-")[1] ?? 0);
+ }
+ const [, fractionalPart = ""] = normalizedStep.split(".");
+ return fractionalPart.length;
+}
+
+export function snapToStep({
+ value,
+ step,
+}: {
+ value: number;
+ step: number;
+}): number {
+ if (step <= 0) return value;
+ const snappedValue = Math.round(value / step) * step;
+ return Number(
+ snappedValue.toFixed(getFractionDigitsForStep({ step })),
+ );
+}
+
+export function isNearlyEqual({
+ leftValue,
+ rightValue,
+ epsilon = 0.0001,
+}: {
+ leftValue: number;
+ rightValue: number;
+ epsilon?: number;
+}): boolean {
+ return Math.abs(leftValue - rightValue) <= epsilon;
+}
+
+export function formatNumberForDisplay({
+ value,
+ fractionDigits,
+ minFractionDigits = 0,
+ maxFractionDigits = 6,
+}: {
+ value: number;
+ fractionDigits?: number;
+ minFractionDigits?: number;
+ maxFractionDigits?: number;
+}): string {
+ const resolvedMaxFractionDigits = Math.max(
+ 0,
+ fractionDigits ?? maxFractionDigits,
+ );
+ const resolvedMinFractionDigits = Math.min(
+ Math.max(0, fractionDigits ?? minFractionDigits),
+ resolvedMaxFractionDigits,
+ );
+ const fixedValue = value.toFixed(resolvedMaxFractionDigits);
+
+ if (resolvedMaxFractionDigits === 0) {
+ return Number(fixedValue) === 0 ? "0" : fixedValue;
+ }
+
+ const [integerPart, fractionPart = ""] = fixedValue.split(".");
+ const normalizedIntegerPart = Number(fixedValue) === 0 ? "0" : integerPart;
+ let trimmedFractionPart = fractionPart;
+
+ while (
+ trimmedFractionPart.length > resolvedMinFractionDigits &&
+ trimmedFractionPart.endsWith("0")
+ ) {
+ trimmedFractionPart = trimmedFractionPart.slice(0, -1);
+ }
+
+ return trimmedFractionPart
+ ? `${normalizedIntegerPart}.${trimmedFractionPart}`
+ : normalizedIntegerPart;
+}
+
+/**
+ * Safely evaluates a mathematical expression without using eval() or new Function().
+ * Supports +, -, *, /, parentheses, and decimal numbers.
+ */
+export function evaluateMathExpression({
+ input,
+}: {
+ input: string;
+}): number | null {
+ const sanitized = input.trim();
+ if (!/^[\d.\s+\-*/()]+$/.test(sanitized)) return null;
+ try {
+ return parseExpression(sanitized);
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Recursive descent parser for basic arithmetic expressions.
+ * Grammar:
+ * expression = term { ('+' | '-') term }
+ * term = factor { ('*' | '/') factor }
+ * factor = number | '(' expression ')'
+ */
+function parseExpression(input: string): number | null {
+ const tokens = tokenize(input);
+ if (tokens.length === 0) return null;
+
+ let index = 0;
+
+ function peek(): Token | null {
+ return tokens[index] ?? null;
+ }
+
+ function consume(): Token | null {
+ return tokens[index++] ?? null;
+ }
+
+ function parseExpressionLevel(): number {
+ let left = parseTerm();
+ while (true) {
+ const token = peek();
+ if (token?.type === "PLUS") {
+ consume();
+ left = left + parseTerm();
+ } else if (token?.type === "MINUS") {
+ consume();
+ left = left - parseTerm();
+ } else {
+ break;
+ }
+ }
+ return left;
+ }
+
+ function parseTerm(): number {
+ let left = parseFactor();
+ while (true) {
+ const token = peek();
+ if (token?.type === "MULTIPLY") {
+ consume();
+ left = left * parseFactor();
+ } else if (token?.type === "DIVIDE") {
+ consume();
+ const right = parseFactor();
+ if (right === 0) throw new Error("Division by zero");
+ left = left / right;
+ } else {
+ break;
+ }
+ }
+ return left;
+ }
+
+ function parseFactor(): number {
+ const token = peek();
+ if (!token) throw new Error("Unexpected end of expression");
+
+ if (token.type === "NUMBER") {
+ consume();
+ return token.value;
+ }
+
+ if (token.type === "MINUS") {
+ consume();
+ return -parseFactor();
+ }
+
+ if (token.type === "LPAREN") {
+ consume();
+ const value = parseExpressionLevel();
+ const next = peek();
+ if (next?.type !== "RPAREN") {
+ throw new Error("Missing closing parenthesis");
+ }
+ consume();
+ return value;
+ }
+
+ throw new Error(`Unexpected token: ${token.type}`);
+ }
+
+ const result = parseExpressionLevel();
+
+ if (index !== tokens.length) {
+ return null;
+ }
+
+ if (!Number.isFinite(result)) return null;
+ return result;
+}
+
+type Token =
+ | { type: "NUMBER"; value: number }
+ | { type: "PLUS" }
+ | { type: "MINUS" }
+ | { type: "MULTIPLY" }
+ | { type: "DIVIDE" }
+ | { type: "LPAREN" }
+ | { type: "RPAREN" };
+
+function tokenize(input: string): Token[] {
+ const tokens: Token[] = [];
+ let i = 0;
+ const str = input.trim();
+
+ while (i < str.length) {
+ const char = str[i];
+
+ if (/\s/.test(char)) {
+ i++;
+ continue;
+ }
+
+ if (/\d/.test(char)) {
+ let numStr = "";
+ while (i < str.length && (/\d/.test(str[i]) || str[i] === ".")) {
+ numStr += str[i];
+ i++;
+ }
+ const value = Number(numStr);
+ if (!Number.isFinite(value)) {
+ throw new Error(`Invalid number: ${numStr}`);
+ }
+ tokens.push({ type: "NUMBER", value });
+ continue;
+ }
+
+ switch (char) {
+ case "+":
+ tokens.push({ type: "PLUS" });
+ break;
+ case "-":
+ tokens.push({ type: "MINUS" });
+ break;
+ case "*":
+ tokens.push({ type: "MULTIPLY" });
+ break;
+ case "/":
+ tokens.push({ type: "DIVIDE" });
+ break;
+ case "(":
+ tokens.push({ type: "LPAREN" });
+ break;
+ case ")":
+ tokens.push({ type: "RPAREN" });
+ break;
+ default:
+ throw new Error(`Invalid character: ${char}`);
+ }
+ i++;
+ }
+
+ return tokens;
+}