mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-05 19:24:39 +00:00
feat: improved calculator
This commit is contained in:
@@ -0,0 +1,17 @@
|
|||||||
|
export default {
|
||||||
|
preset: 'ts-jest',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
roots: ['<rootDir>/src'],
|
||||||
|
testMatch: [
|
||||||
|
'**/__tests__/**/*.ts',
|
||||||
|
'**/?(*.)+(spec|test).ts'
|
||||||
|
],
|
||||||
|
transform: {
|
||||||
|
'^.+\\.ts$': 'ts-jest',
|
||||||
|
},
|
||||||
|
moduleFileExtensions: ['ts', 'js', 'json'],
|
||||||
|
collectCoverageFrom: [
|
||||||
|
'src/**/*.ts',
|
||||||
|
'!src/**/*.d.ts',
|
||||||
|
],
|
||||||
|
};
|
||||||
+7
-1
@@ -16,7 +16,10 @@
|
|||||||
"dependency-graph": "depcruise src --include-only \"^src\" --output-type dot | dot -T svg > dependency-graph.svg",
|
"dependency-graph": "depcruise src --include-only \"^src\" --output-type dot | dot -T svg > dependency-graph.svg",
|
||||||
"release": "gh release create $npm_package_name@$npm_package_version ./dist/*.zip --generate-notes",
|
"release": "gh release create $npm_package_name@$npm_package_version ./dist/*.zip --generate-notes",
|
||||||
"publish": "bun lib/publish.js --b",
|
"publish": "bun lib/publish.js --b",
|
||||||
"zip": "bedframe zip"
|
"zip": "bedframe zip",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:coverage": "jest --coverage"
|
||||||
},
|
},
|
||||||
"targets": {
|
"targets": {
|
||||||
"prod": {
|
"prod": {
|
||||||
@@ -37,6 +40,7 @@
|
|||||||
"@babel/runtime": "^7.26.9",
|
"@babel/runtime": "^7.26.9",
|
||||||
"@bedframe/cli": "^0.0.91",
|
"@bedframe/cli": "^0.0.91",
|
||||||
"@crxjs/vite-plugin": "2.0.0-beta.32",
|
"@crxjs/vite-plugin": "2.0.0-beta.32",
|
||||||
|
"@types/jest": "^29.5.14",
|
||||||
"@types/mime-types": "^2.1.4",
|
"@types/mime-types": "^2.1.4",
|
||||||
"@types/react": "^19.0.10",
|
"@types/react": "^19.0.10",
|
||||||
"@types/react-dom": "^19.0.4",
|
"@types/react-dom": "^19.0.4",
|
||||||
@@ -44,6 +48,7 @@
|
|||||||
"dependency-cruiser": "^16.10.0",
|
"dependency-cruiser": "^16.10.0",
|
||||||
"eslint": "9.22.0",
|
"eslint": "9.22.0",
|
||||||
"glob": "^11.0.1",
|
"glob": "^11.0.1",
|
||||||
|
"jest": "^29.7.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"process": "^0.11.10",
|
"process": "^0.11.10",
|
||||||
@@ -52,6 +57,7 @@
|
|||||||
"sass-loader": "^16.0.5",
|
"sass-loader": "^16.0.5",
|
||||||
"semver": "^7.7.1",
|
"semver": "^7.7.1",
|
||||||
"tailwindcss": "3",
|
"tailwindcss": "3",
|
||||||
|
"ts-jest": "^29.3.4",
|
||||||
"url": "^0.11.4"
|
"url": "^0.11.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createEventDispatcher, onDestroy } from 'svelte';
|
import { createEventDispatcher, onDestroy } from 'svelte';
|
||||||
import { unitFullNames } from './unitMap';
|
import { calculateExpression } from '../utils/calculator';
|
||||||
import * as math from 'mathjs';
|
|
||||||
|
|
||||||
let { searchTerm = '', isSelected = false } = $props<{ searchTerm: string, isSelected: boolean }>();
|
let { searchTerm = '', isSelected = false } = $props<{ searchTerm: string, isSelected: boolean }>();
|
||||||
|
|
||||||
@@ -13,73 +12,32 @@
|
|||||||
let isCalculating = $state(false);
|
let isCalculating = $state(false);
|
||||||
let inputUnit = $state<string>('');
|
let inputUnit = $state<string>('');
|
||||||
let outputUnit = $state<string>('');
|
let outputUnit = $state<string>('');
|
||||||
|
let isPartial = $state(false);
|
||||||
function detectUnit(expression: string): string {
|
|
||||||
try {
|
|
||||||
const unit = math.unit(expression);
|
|
||||||
if (unit) {
|
|
||||||
// Get the base unit name
|
|
||||||
const unitStr = unit.formatUnits();
|
|
||||||
return unitFullNames[unitStr] || unitStr;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Not a unit or invalid expression
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process the input with debounce to avoid unnecessary calculations
|
|
||||||
const processInput = (input: string) => {
|
const processInput = (input: string) => {
|
||||||
|
isCalculating = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (
|
const calcResult = calculateExpression(input);
|
||||||
!input.trim() ||
|
|
||||||
(input.trim().length <= 2 && !/\d/.test(input))
|
|
||||||
) {
|
|
||||||
result = null;
|
|
||||||
inputUnit = '';
|
|
||||||
outputUnit = '';
|
|
||||||
dispatch('hasResult', null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isCalculating = true;
|
if (calcResult.isValid) {
|
||||||
|
result = calcResult.result;
|
||||||
// Let mathjs handle everything
|
inputUnit = calcResult.inputUnit;
|
||||||
const evaluated = math.evaluate(input.replace('**', '^'));
|
outputUnit = calcResult.outputUnit;
|
||||||
|
isPartial = calcResult.isPartial;
|
||||||
// Format the result
|
dispatch('hasResult', calcResult.result);
|
||||||
if (evaluated !== undefined) {
|
|
||||||
if (math.typeOf(evaluated) === 'Unit') {
|
|
||||||
// Handle unit conversion results
|
|
||||||
result = math.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
|
|
||||||
inputUnit = detectUnit(input);
|
|
||||||
outputUnit = detectUnit(result);
|
|
||||||
} else if (typeof evaluated === 'number') {
|
|
||||||
// Handle regular numbers
|
|
||||||
if (math.round(evaluated) === evaluated) {
|
|
||||||
result = math.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
|
|
||||||
} else {
|
|
||||||
result = math.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
|
|
||||||
}
|
|
||||||
inputUnit = '';
|
|
||||||
outputUnit = '';
|
|
||||||
} else {
|
|
||||||
result = math.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
|
|
||||||
inputUnit = '';
|
|
||||||
outputUnit = '';
|
|
||||||
}
|
|
||||||
dispatch('hasResult', result);
|
|
||||||
} else {
|
} else {
|
||||||
result = null;
|
result = null;
|
||||||
inputUnit = '';
|
inputUnit = '';
|
||||||
outputUnit = '';
|
outputUnit = '';
|
||||||
|
isPartial = false;
|
||||||
dispatch('hasResult', null);
|
dispatch('hasResult', null);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// If mathjs throws an error, this isn't a valid expression
|
|
||||||
result = null;
|
result = null;
|
||||||
inputUnit = '';
|
inputUnit = '';
|
||||||
outputUnit = '';
|
outputUnit = '';
|
||||||
|
isPartial = false;
|
||||||
dispatch('hasResult', null);
|
dispatch('hasResult', null);
|
||||||
} finally {
|
} finally {
|
||||||
isCalculating = false;
|
isCalculating = false;
|
||||||
@@ -96,7 +54,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if result !== null}
|
{#if result !== null}
|
||||||
<div class="p-2">
|
<div class="">
|
||||||
<p class="text-[0.85rem] p-1 pb-0.5 pt-0 font-semibold text-zinc-500 dark:text-zinc-400">Calculator</p>
|
<p class="text-[0.85rem] p-1 pb-0.5 pt-0 font-semibold text-zinc-500 dark:text-zinc-400">Calculator</p>
|
||||||
<div class="flex items-center justify-between gap-8 rounded-lg border border-transparent {isSelected ? 'bg-zinc-900/5 dark:bg-white/10 border-zinc-900/5 dark:border-zinc-100/5' : ''}">
|
<div class="flex items-center justify-between gap-8 rounded-lg border border-transparent {isSelected ? 'bg-zinc-900/5 dark:bg-white/10 border-zinc-900/5 dark:border-zinc-100/5' : ''}">
|
||||||
<div class="flex flex-col flex-1 items-center py-4 pl-4 min-w-0">
|
<div class="flex flex-col flex-1 items-center py-4 pl-4 min-w-0">
|
||||||
@@ -124,7 +82,7 @@
|
|||||||
{result}
|
{result}
|
||||||
</div>
|
</div>
|
||||||
<div class="px-3 py-1 mt-1 text-sm rounded-md text-zinc-900 dark:text-zinc-300 bg-zinc-100 dark:bg-zinc-100/10">
|
<div class="px-3 py-1 mt-1 text-sm rounded-md text-zinc-900 dark:text-zinc-300 bg-zinc-100 dark:bg-zinc-100/10">
|
||||||
{outputUnit || 'Result'}
|
{outputUnit || (isPartial ? 'Partial' : 'Result')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import * as math from 'mathjs';
|
||||||
|
import { unitFullNames } from './unitMap';
|
||||||
|
|
||||||
|
export interface CalculatorResult {
|
||||||
|
result: string | null;
|
||||||
|
isValid: boolean;
|
||||||
|
isPartial: boolean;
|
||||||
|
inputUnit: string;
|
||||||
|
outputUnit: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expandedMath = math.create(math.all);
|
||||||
|
|
||||||
|
expandedMath.import({
|
||||||
|
five: 5,
|
||||||
|
ten: 10,
|
||||||
|
three: 3,
|
||||||
|
four: 4,
|
||||||
|
eight: 8,
|
||||||
|
sixteen: 16,
|
||||||
|
twenty: 20,
|
||||||
|
twentyfive: 25,
|
||||||
|
fifty: 50,
|
||||||
|
hundred: 100,
|
||||||
|
plus: (a: number, b: number) => a + b,
|
||||||
|
minus: (a: number, b: number) => a - b,
|
||||||
|
times: (a: number, b: number) => a * b,
|
||||||
|
divided: (a: number, b: number) => a / b,
|
||||||
|
power: (a: number, b: number) => Math.pow(a, b),
|
||||||
|
half: (a: number) => a / 2,
|
||||||
|
double: (a: number) => a * 2,
|
||||||
|
quarter: (a: number) => a / 4,
|
||||||
|
|
||||||
|
// String functions
|
||||||
|
length: (str: string) => str.length,
|
||||||
|
concat: (...args: string[]) => args.join(''),
|
||||||
|
uppercase: (str: string) => str.toUpperCase(),
|
||||||
|
lowercase: (str: string) => str.toLowerCase(),
|
||||||
|
substr: (str: string, start: number, length: number) => str.substr(start, length),
|
||||||
|
|
||||||
|
// Random functions
|
||||||
|
randomInt: (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min,
|
||||||
|
|
||||||
|
// Comparison and Boolean operations
|
||||||
|
and: (a: boolean, b: boolean) => a && b,
|
||||||
|
or: (a: boolean, b: boolean) => a || b,
|
||||||
|
not: (a: boolean) => !a,
|
||||||
|
|
||||||
|
// Combinatorics
|
||||||
|
permutations: (n: number, r: number) => expandedMath.combinations(n, r) * expandedMath.factorial(r),
|
||||||
|
nPr: (n: number, r: number) => expandedMath.combinations(n, r) * expandedMath.factorial(r),
|
||||||
|
nCr: (n: number, r: number) => expandedMath.combinations(n, r),
|
||||||
|
|
||||||
|
// Number theory
|
||||||
|
gcd: (a: number, b: number) => expandedMath.gcd(a, b),
|
||||||
|
lcm: (a: number, b: number) => expandedMath.lcm(a, b),
|
||||||
|
|
||||||
|
// Precision functions
|
||||||
|
precision: (num: number, digits: number) => parseFloat(num.toPrecision(digits)),
|
||||||
|
fix: (num: number, digits: number) => parseFloat(num.toFixed(digits)),
|
||||||
|
|
||||||
|
// Percentage operations
|
||||||
|
percent: (value: number) => value / 100,
|
||||||
|
|
||||||
|
// Financial operations
|
||||||
|
compound: (principal: number, rate: number, time: number) => principal * Math.pow(1 + rate, time),
|
||||||
|
}, { override: true });
|
||||||
|
|
||||||
|
function detectUnit(expression: string): string {
|
||||||
|
try {
|
||||||
|
const unit = expandedMath.unit(expression);
|
||||||
|
if (unit) {
|
||||||
|
const unitStr = unit.formatUnits();
|
||||||
|
return unitFullNames[unitStr] || unitStr;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Not a unit or invalid expression
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLikelyMathExpression(input: string): boolean {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
|
||||||
|
// Must contain at least one digit or mathematical operator
|
||||||
|
if (!/[\d+\-*/^()=.]/.test(trimmed)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for common non-math words that shouldn't trigger calculator
|
||||||
|
const nonMathWords = ['abs', 'function', 'class', 'const', 'let', 'var', 'if', 'else', 'while', 'for', 'return', 'import', 'export'];
|
||||||
|
const words = trimmed.toLowerCase().split(/\s+/);
|
||||||
|
|
||||||
|
// If it's just a single non-math word, skip it
|
||||||
|
if (words.length === 1 && nonMathWords.includes(words[0]) && !/\d/.test(trimmed)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must have some mathematical content
|
||||||
|
const mathPattern = /(\d+\.?\d*|\+|\-|\*|\/|\^|\(|\)|sin|cos|tan|log|sqrt|pi|e|=)/i;
|
||||||
|
return mathPattern.test(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryCompleteExpression(expression: string): string | null {
|
||||||
|
const trimmed = expression.trim();
|
||||||
|
|
||||||
|
// Common patterns for incomplete expressions
|
||||||
|
const incompletePatterns = [
|
||||||
|
/[\+\-\*\/\^]\s*$/, // ends with operator
|
||||||
|
/\(\s*$/, // ends with opening parenthesis
|
||||||
|
/[\+\-\*\/\^]\s*\(/, // operator followed by opening parenthesis
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of incompletePatterns) {
|
||||||
|
if (pattern.test(trimmed)) {
|
||||||
|
// Try to evaluate what we have so far by removing the incomplete part
|
||||||
|
let partial = trimmed.replace(/[\+\-\*\/\^]\s*$/, '').trim();
|
||||||
|
|
||||||
|
// Handle cases like "4 + 3 *" -> evaluate "4 + 3"
|
||||||
|
if (partial && !partial.match(/[\+\-\*\/\^]\s*$/)) {
|
||||||
|
try {
|
||||||
|
const result = expandedMath.evaluate(partial);
|
||||||
|
if (typeof result === 'number' && !isNaN(result)) {
|
||||||
|
return expandedMath.format(result, { precision: 14, lowerExp: -15, upperExp: 15 });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Continue to other attempts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateExpression(input: string): CalculatorResult {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
|
||||||
|
// Early exit for empty or very short inputs
|
||||||
|
if (!trimmed || (trimmed.length <= 2 && !/\d/.test(trimmed))) {
|
||||||
|
return {
|
||||||
|
result: null,
|
||||||
|
isValid: false,
|
||||||
|
isPartial: false,
|
||||||
|
inputUnit: '',
|
||||||
|
outputUnit: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this looks like a math expression at all
|
||||||
|
if (!isLikelyMathExpression(trimmed)) {
|
||||||
|
return {
|
||||||
|
result: null,
|
||||||
|
isValid: false,
|
||||||
|
isPartial: false,
|
||||||
|
inputUnit: '',
|
||||||
|
outputUnit: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// First try to evaluate the expression as-is
|
||||||
|
const evaluated = expandedMath.evaluate(trimmed.replace('**', '^'));
|
||||||
|
|
||||||
|
if (evaluated !== undefined) {
|
||||||
|
let result: string;
|
||||||
|
let inputUnit = '';
|
||||||
|
let outputUnit = '';
|
||||||
|
|
||||||
|
if (math.typeOf(evaluated) === 'Unit') {
|
||||||
|
// Handle unit conversion results
|
||||||
|
result = expandedMath.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
|
||||||
|
inputUnit = detectUnit(trimmed);
|
||||||
|
outputUnit = detectUnit(result);
|
||||||
|
} else if (typeof evaluated === 'number') {
|
||||||
|
// Handle regular numbers
|
||||||
|
result = math.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
|
||||||
|
} else {
|
||||||
|
result = math.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
result,
|
||||||
|
isValid: true,
|
||||||
|
isPartial: false,
|
||||||
|
inputUnit,
|
||||||
|
outputUnit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Try to handle incomplete expressions
|
||||||
|
const partialResult = tryCompleteExpression(trimmed);
|
||||||
|
|
||||||
|
if (partialResult) {
|
||||||
|
return {
|
||||||
|
result: partialResult,
|
||||||
|
isValid: true,
|
||||||
|
isPartial: true,
|
||||||
|
inputUnit: '',
|
||||||
|
outputUnit: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it still looks like math but failed, return the error
|
||||||
|
return {
|
||||||
|
result: null,
|
||||||
|
isValid: false,
|
||||||
|
isPartial: false,
|
||||||
|
inputUnit: '',
|
||||||
|
outputUnit: '',
|
||||||
|
error: error instanceof Error ? error.message : 'Invalid expression',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
result: null,
|
||||||
|
isValid: false,
|
||||||
|
isPartial: false,
|
||||||
|
inputUnit: '',
|
||||||
|
outputUnit: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
+28
-1
@@ -177,7 +177,7 @@ export const unitFullNames: Record<string, string> = {
|
|||||||
|
|
||||||
// --- Currency ---
|
// --- Currency ---
|
||||||
USD: "US Dollars",
|
USD: "US Dollars",
|
||||||
EUR: "Euros",
|
EUR: "Euros",
|
||||||
GBP: "British Pounds",
|
GBP: "British Pounds",
|
||||||
AUD: "Australian Dollars",
|
AUD: "Australian Dollars",
|
||||||
CAD: "Canadian Dollars",
|
CAD: "Canadian Dollars",
|
||||||
@@ -190,4 +190,31 @@ export const unitFullNames: Record<string, string> = {
|
|||||||
NOK: "Norwegian Krone",
|
NOK: "Norwegian Krone",
|
||||||
SGD: "Singapore Dollars",
|
SGD: "Singapore Dollars",
|
||||||
HKD: "Hong Kong Dollars",
|
HKD: "Hong Kong Dollars",
|
||||||
|
KRW: "South Korean Won",
|
||||||
|
MXN: "Mexican Peso",
|
||||||
|
BRL: "Brazilian Real",
|
||||||
|
RUB: "Russian Ruble",
|
||||||
|
TRY: "Turkish Lira",
|
||||||
|
ZAR: "South African Rand",
|
||||||
|
PLN: "Polish Zloty",
|
||||||
|
THB: "Thai Baht",
|
||||||
|
DKK: "Danish Krone",
|
||||||
|
CZK: "Czech Koruna",
|
||||||
|
HUF: "Hungarian Forint",
|
||||||
|
ILS: "Israeli Shekel",
|
||||||
|
PHP: "Philippine Peso",
|
||||||
|
TWD: "Taiwan Dollar",
|
||||||
|
MYR: "Malaysian Ringgit",
|
||||||
|
|
||||||
|
// --- Cryptocurrencies ---
|
||||||
|
BTC: "Bitcoin",
|
||||||
|
ETH: "Ethereum",
|
||||||
|
XRP: "Ripple",
|
||||||
|
LTC: "Litecoin",
|
||||||
|
ADA: "Cardano",
|
||||||
|
DOT: "Polkadot",
|
||||||
|
SOL: "Solana",
|
||||||
|
MATIC: "Polygon",
|
||||||
|
AVAX: "Avalanche",
|
||||||
|
UNI: "Uniswap",
|
||||||
};
|
};
|
||||||
+1
-1
@@ -28,7 +28,7 @@
|
|||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
},
|
},
|
||||||
"types": ["vite/client", "node"]
|
"types": ["vite/client", "node", "jest"]
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src/**/*.ts",
|
"src/**/*.ts",
|
||||||
|
|||||||
Reference in New Issue
Block a user