diff --git a/package-lock.json b/package-lock.json index 8d0f480..69f255b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@mui/material": "^5.15.20", "@playwright/test": "^1.45.0", "@types/ffmpeg": "^1.0.7", + "@types/js-quantities": "^1.6.6", "@types/lodash": "^4.17.5", "@types/morsee": "^1.0.2", "@types/omggif": "^1.0.5", @@ -27,10 +28,12 @@ "dayjs": "^1.11.13", "formik": "^2.4.6", "jimp": "^0.22.12", + "js-quantities": "^1.8.0", "lint-staged": "^15.4.3", "lodash": "^4.17.21", "mime": "^4.0.6", "morsee": "^1.0.9", + "nerdamer-prime": "^1.2.4", "notistack": "^3.0.1", "omggif": "^1.0.10", "pdf-lib": "^1.17.1", @@ -3113,6 +3116,11 @@ "hoist-non-react-statics": "^3.3.0" } }, + "node_modules/@types/js-quantities": { + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/@types/js-quantities/-/js-quantities-1.6.6.tgz", + "integrity": "sha512-k2Q8/Avj4Oz50flfTnfVGnUCkt7OYQ3U+lfQVELE/x5mdbwChZ7fM0wpUpVWzbuSL8kIYt9ZsFQ0RFNBv8T3Qw==" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -6989,6 +6997,11 @@ "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==", "license": "BSD-3-Clause" }, + "node_modules/js-quantities": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/js-quantities/-/js-quantities-1.8.0.tgz", + "integrity": "sha512-swDw9RJpXACAWR16vAKoSojAsP6NI7cZjjnjKqhOyZSdybRUdmPr071foD3fejUKSU2JMHz99hflWkRWvfLTpQ==" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -7840,6 +7853,14 @@ "is-buffer": "^1.0.2" } }, + "node_modules/nerdamer-prime": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/nerdamer-prime/-/nerdamer-prime-1.2.4.tgz", + "integrity": "sha512-oaGnI7GUTj3T2k2PkeGf/694uLY9pYwFSOkn/5aTuz1RXdJeD6hN0+2csKagB65H6R8J1Zb7UlPq7zEzQ2dumw==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", diff --git a/package.json b/package.json index c79dd59..6b1cb59 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@mui/material": "^5.15.20", "@playwright/test": "^1.45.0", "@types/ffmpeg": "^1.0.7", + "@types/js-quantities": "^1.6.6", "@types/lodash": "^4.17.5", "@types/morsee": "^1.0.2", "@types/omggif": "^1.0.5", @@ -44,10 +45,12 @@ "dayjs": "^1.11.13", "formik": "^2.4.6", "jimp": "^0.22.12", + "js-quantities": "^1.8.0", "lint-staged": "^15.4.3", "lodash": "^4.17.21", "mime": "^4.0.6", "morsee": "^1.0.9", + "nerdamer-prime": "^1.2.4", "notistack": "^3.0.1", "omggif": "^1.0.10", "pdf-lib": "^1.17.1", diff --git a/src/components/ToolContent.tsx b/src/components/ToolContent.tsx index 2e3a6b6..703128e 100644 --- a/src/components/ToolContent.tsx +++ b/src/components/ToolContent.tsx @@ -40,7 +40,7 @@ const FormikListenerComponent = ({ interface ToolContentProps extends ToolComponentProps { inputComponent?: ReactNode; - resultComponent: ReactNode; + resultComponent?: ReactNode; renderCustomInput?: ( values: T, setFieldValue: (fieldName: string, value: any) => void @@ -57,6 +57,7 @@ interface ToolContentProps extends ToolComponentProps { setInput?: React.Dispatch>; validationSchema?: any; onValuesChange?: (values: T) => void; + verticalGroups?: boolean; } export default function ToolContent({ @@ -72,7 +73,8 @@ export default function ToolContent({ setInput, validationSchema, renderCustomInput, - onValuesChange + onValuesChange, + verticalGroups }: ToolContentProps) { return ( @@ -97,7 +99,7 @@ export default function ToolContent({ input={input} onValuesChange={onValuesChange} /> - + {toolInfo && toolInfo.title && toolInfo.description && ( - {input && ( - - {input} + if (input || result) { + return ( + + {input && ( + + {input} + + )} + + {result} - )} - - {result} - - ); + ); + } } diff --git a/src/components/input/NumericInputWithUnit.tsx b/src/components/input/NumericInputWithUnit.tsx new file mode 100644 index 0000000..a545685 --- /dev/null +++ b/src/components/input/NumericInputWithUnit.tsx @@ -0,0 +1,178 @@ +import React, { useState, useEffect } from 'react'; +import { Grid, Select, MenuItem } from '@mui/material'; +import TextFieldWithDesc from '@components/options/TextFieldWithDesc'; +import Qty from 'js-quantities'; +// + +const siPrefixes: { [key: string]: number } = { + 'Default prefix': 1, + k: 1000, + M: 1000000, + G: 1000000000, + T: 1000000000000, + m: 0.001, + u: 0.000001, + n: 0.000000001, + p: 0.000000000001 +}; + +export default function NumericInputWithUnit(props: { + value: { value: number; unit: string }; + disabled?: boolean; + disableChangingUnit?: boolean; + onOwnChange?: (value: { value: number; unit: string }) => void; + defaultPrefix?: string; +}) { + const [inputValue, setInputValue] = useState(props.value.value); + const [prefix, setPrefix] = useState(props.defaultPrefix || 'Default prefix'); + + // internal display unit + const [unit, setUnit] = useState(''); + + // Whether user has overridden the unit + const [userSelectedUnit, setUserSelectedUnit] = useState(false); + const [unitKind, setUnitKind] = useState(''); + const [unitOptions, setUnitOptions] = useState([]); + + const [disabled, setDisabled] = useState(props.disabled); + const [disableChangingUnit, setDisableChangingUnit] = useState( + props.disableChangingUnit + ); + + useEffect(() => { + setDisabled(props.disabled); + setDisableChangingUnit(props.disableChangingUnit); + }, [props.disabled, props.disableChangingUnit]); + + useEffect(() => { + if (unitKind != Qty(props.value.unit).kind()) { + // Update the options for what units similar to this one are available + const kind = Qty(props.value.unit).kind(); + let units: string[] = []; + if (kind) { + units = Qty.getUnits(kind); + } + + if (!units.includes(props.value.unit)) { + units.push(props.value.unit); + } + + // Workaround because the lib doesn't list them + if (kind == 'area') { + units.push('km^2'); + units.push('mile^2'); + units.push('inch^2'); + units.push('m^2'); + units.push('cm^2'); + } + setUnitOptions(units); + setInputValue(props.value.value); + setUnit(props.value.unit); + setUnitKind(kind); + setUserSelectedUnit(false); + return; + } + + if (userSelectedUnit) { + if (!isNaN(props.value.value)) { + const converted = Qty(props.value.value, props.value.unit).to( + unit + ).scalar; + setInputValue(converted); + } else { + setInputValue(props.value.value); + } + } else { + setInputValue(props.value.value); + setUnit(props.value.unit); + } + }, [props.value.value, props.value.unit, unit]); + + const handleUserValueChange = (newValue: number) => { + setInputValue(newValue); + + if (props.onOwnChange) { + try { + const converted = Qty(newValue * siPrefixes[prefix], unit).to( + props.value.unit + ).scalar; + + props.onOwnChange({ unit: props.value.unit, value: converted }); + } catch (error) { + console.error('Conversion error', error); + } + } + }; + + const handlePrefixChange = (newPrefix: string) => { + setPrefix(newPrefix); + }; + + const handleUserUnitChange = (newUnit: string) => { + if (!newUnit) return; + const oldInputValue = inputValue; + const oldUnit = unit; + setUnit(newUnit); + setPrefix('Default prefix'); + + const convertedValue = Qty(oldInputValue * siPrefixes[prefix], oldUnit).to( + newUnit + ).scalar; + setInputValue(convertedValue); + }; + + return ( + + + handleUserValueChange(parseFloat(value))} + /> + + + + + + + + + + + ); +} diff --git a/src/components/options/ToolOptions.tsx b/src/components/options/ToolOptions.tsx index 15a1844..c145fca 100644 --- a/src/components/options/ToolOptions.tsx +++ b/src/components/options/ToolOptions.tsx @@ -13,10 +13,12 @@ export type GetGroupsType = ( export default function ToolOptions({ children, - getGroups + getGroups, + vertical }: { children?: ReactNode; getGroups: GetGroupsType | null; + vertical?: boolean; }) { const theme = useTheme(); const formikContext = useFormikContext(); @@ -49,6 +51,7 @@ export default function ToolOptions({ {children} diff --git a/src/datatables/data/material_electrical_properties.ts b/src/datatables/data/material_electrical_properties.ts new file mode 100644 index 0000000..246bfd9 --- /dev/null +++ b/src/datatables/data/material_electrical_properties.ts @@ -0,0 +1,18 @@ +export default { + title: 'Material Electrical Properties', + columns: { + resistivity_20c: { + title: 'Resistivity at 20°C', + type: 'number', + unit: 'Ω/m' + } + }, + data: { + Copper: { + resistivity_20c: 1.68e-8 + }, + Aluminum: { + resistivity_20c: 2.82e-8 + } + } +}; diff --git a/src/datatables/data/wire_gauge.ts b/src/datatables/data/wire_gauge.ts new file mode 100644 index 0000000..13bbfa7 --- /dev/null +++ b/src/datatables/data/wire_gauge.ts @@ -0,0 +1,73 @@ +import type { DataTable } from '../types'; + +const data: DataTable = { + title: 'American Wire Gauge', + columns: { + diameter: { + title: 'Diameter', + type: 'number', + unit: 'mm' + }, + area: { + title: 'Area', + type: 'number', + unit: 'mm2' + } + }, + data: { + '0000 AWG': { diameter: 11.684 }, + '000 AWG': { diameter: 10.405 }, + '00 AWG': { diameter: 9.266 }, + '0 AWG': { diameter: 8.251 }, + '(4/0) AWG': { diameter: 11.684 }, + '(3/0) AWG': { diameter: 10.405 }, + '(2/0) AWG': { diameter: 9.266 }, + '(1/0) AWG': { diameter: 8.251 }, + '1 AWG': { diameter: 7.348 }, + '2 AWG': { diameter: 6.544 }, + '3 AWG': { diameter: 5.827 }, + '4 AWG': { diameter: 5.189 }, + '5 AWG': { diameter: 4.621 }, + '6 AWG': { diameter: 4.115 }, + '7 AWG': { diameter: 3.665 }, + '8 AWG': { diameter: 3.264 }, + '9 AWG': { diameter: 2.906 }, + '10 AWG': { diameter: 2.588 }, + '11 AWG': { diameter: 2.305 }, + '12 AWG': { diameter: 2.053 }, + '13 AWG': { diameter: 1.828 }, + '14 AWG': { diameter: 1.628 }, + '15 AWG': { diameter: 1.45 }, + '16 AWG': { diameter: 1.291 }, + '17 AWG': { diameter: 1.15 }, + '18 AWG': { diameter: 1.024 }, + '19 AWG': { diameter: 0.912 }, + '20 AWG': { diameter: 0.812 }, + '21 AWG': { diameter: 0.723 }, + '22 AWG': { diameter: 0.644 }, + '23 AWG': { diameter: 0.573 }, + '24 AWG': { diameter: 0.511 }, + '25 AWG': { diameter: 0.455 }, + '26 AWG': { diameter: 0.405 }, + '27 AWG': { diameter: 0.361 }, + '28 AWG': { diameter: 0.321 }, + '29 AWG': { diameter: 0.286 }, + '30 AWG': { diameter: 0.255 }, + '31 AWG': { diameter: 0.227 }, + '32 AWG': { diameter: 0.202 }, + '33 AWG': { diameter: 0.18 }, + '34 AWG': { diameter: 0.16 }, + '35 AWG': { diameter: 0.143 }, + '36 AWG': { diameter: 0.127 }, + '37 AWG': { diameter: 0.113 }, + '38 AWG': { diameter: 0.101 }, + '39 AWG': { diameter: 0.0897 }, + '40 AWG': { diameter: 0.0799 } + } +}; + +for (const key in data.data) { + data.data[key].area = Math.PI * (data.data[key].diameter / 2) ** 2; +} + +export default data; diff --git a/src/datatables/index.ts b/src/datatables/index.ts new file mode 100644 index 0000000..e661d11 --- /dev/null +++ b/src/datatables/index.ts @@ -0,0 +1,8 @@ +import type { DataTable } from './types.ts'; + +/* Used in case later we want any kind of computed extra data */ +export function dataTableLookup(table: DataTable, key: string): any { + return table.data[key]; +} + +export { DataTable }; diff --git a/src/datatables/types.ts b/src/datatables/types.ts new file mode 100644 index 0000000..6a77ccc --- /dev/null +++ b/src/datatables/types.ts @@ -0,0 +1,17 @@ +/* +Represents a set of rows indexed by a key. +Used for calculator presets + +*/ +export interface DataTable { + title: string; + /* A JSON schema properties */ + columns: { + [key: string]: { title: string; type: string; unit: string }; + }; + data: { + [key: string]: { + [key: string]: any; + }; + }; +} diff --git a/src/pages/tools/number/generic-calc/data/index.ts b/src/pages/tools/number/generic-calc/data/index.ts new file mode 100644 index 0000000..1a700db --- /dev/null +++ b/src/pages/tools/number/generic-calc/data/index.ts @@ -0,0 +1,6 @@ +import ohmslaw from './ohmsLaw'; +import voltageDropInWire from './voltageDropInWire'; +import sphereArea from './sphereArea'; +import sphereVolume from './sphereVolume'; + +export default [ohmslaw, voltageDropInWire, sphereArea, sphereVolume]; diff --git a/src/pages/tools/number/generic-calc/data/ohmsLaw.ts b/src/pages/tools/number/generic-calc/data/ohmsLaw.ts new file mode 100644 index 0000000..237c71d --- /dev/null +++ b/src/pages/tools/number/generic-calc/data/ohmsLaw.ts @@ -0,0 +1,46 @@ +import type { GenericCalcType } from './types'; + +const ohmsLawCalc: GenericCalcType = { + icon: 'mdi:ohm', + keywords: [ + 'ohm', + 'voltage', + 'current', + 'resistance', + 'electrical', + 'circuit', + 'electronics', + 'power', + 'V=IR' + ], + shortDescription: + "Calculate voltage, current, or resistance in electrical circuits using Ohm's Law", + name: "Ohm's Law", + path: 'ohms-law', + description: 'Calculates voltage, current and resistance', + longDescription: + "This calculator applies Ohm's Law (V = I × R) to determine any of the three electrical parameters when the other two are known. Ohm's Law is a fundamental principle in electrical engineering that describes the relationship between voltage (V), current (I), and resistance (R). This tool is essential for electronics hobbyists, electrical engineers, and students working with circuits to quickly solve for unknown values in their electrical designs.", + formula: 'V = I * R', + presets: [], + variables: [ + { + name: 'V', + title: 'Voltage', + unit: 'volt', + default: 5 + }, + { + name: 'I', + title: 'Current', + unit: 'ampere', + default: 1 + }, + { + name: 'R', + title: 'Resistance', + unit: 'ohm' + } + ] +}; + +export default ohmsLawCalc; diff --git a/src/pages/tools/number/generic-calc/data/sphereArea.ts b/src/pages/tools/number/generic-calc/data/sphereArea.ts new file mode 100644 index 0000000..15a8d13 --- /dev/null +++ b/src/pages/tools/number/generic-calc/data/sphereArea.ts @@ -0,0 +1,41 @@ +import type { GenericCalcType } from './types'; + +const areaSphere: GenericCalcType = { + icon: 'ph:sphere-duotone', + keywords: [ + 'sphere', + 'area', + 'surface area', + 'geometry', + 'mathematics', + 'radius', + 'calculation', + '3D', + 'shape' + ], + shortDescription: + 'Calculate the surface area of a sphere based on its radius', + name: 'Area of a Sphere', + path: 'area-sphere', + description: 'Area of a Sphere', + longDescription: + 'This calculator determines the surface area of a sphere using the formula A = 4πr². You can either input the radius to find the surface area or enter the surface area to calculate the required radius. This tool is useful for students studying geometry, engineers working with spherical objects, and anyone needing to perform calculations involving spherical surfaces.', + formula: 'A = 4 * pi * r**2', + presets: [], + variables: [ + { + name: 'A', + title: 'Area', + unit: 'mm2' + }, + { + name: 'r', + title: 'Radius', + formula: 'r = sqrt(A/pi) / 2', + unit: 'mm', + default: 1 + } + ] +}; + +export default areaSphere; diff --git a/src/pages/tools/number/generic-calc/data/sphereVolume.ts b/src/pages/tools/number/generic-calc/data/sphereVolume.ts new file mode 100644 index 0000000..b6132bc --- /dev/null +++ b/src/pages/tools/number/generic-calc/data/sphereVolume.ts @@ -0,0 +1,47 @@ +import type { GenericCalcType } from './types'; + +const volumeSphere: GenericCalcType = { + icon: 'gravity-ui:sphere', + keywords: [ + 'sphere', + 'volume', + 'geometry', + 'mathematics', + 'radius', + 'diameter', + 'calculation', + '3D', + 'shape', + 'capacity' + ], + shortDescription: 'Calculate the volume of a sphere using radius or diameter', + name: 'Volume of a Sphere', + path: 'volume-sphere', + description: 'Volume of a Sphere', + longDescription: + 'This calculator computes the volume of a sphere using the formula V = (4/3)πr³. You can input either the radius or diameter to find the volume, or enter the volume to determine the required radius. The tool is valuable for students, engineers, and professionals working with spherical objects in fields such as physics, engineering, and manufacturing.', + formula: 'v = (4/3) * pi * r**3', + presets: [], + variables: [ + { + name: 'v', + title: 'Volume', + unit: 'mm3' + }, + { + name: 'r', + title: 'Radius', + unit: 'mm', + default: 1, + alternates: [ + { + title: 'Diameter', + formula: 'x = 2 * v', + unit: 'mm' + } + ] + } + ] +}; + +export default volumeSphere; diff --git a/src/pages/tools/number/generic-calc/data/types.ts b/src/pages/tools/number/generic-calc/data/types.ts new file mode 100644 index 0000000..e4fc69d --- /dev/null +++ b/src/pages/tools/number/generic-calc/data/types.ts @@ -0,0 +1,49 @@ +import { DataTable } from '../../../../../datatables'; +import { ToolMeta } from '@tools/defineTool'; + +export interface AlternativeVarInfo { + title: string; + unit: string; + defaultPrefix?: string; + formula: string; +} + +export interface GenericCalcType extends Omit { + formula: string; + extraOutputs?: { + title: string; + formula: string; + unit: string; + // Si prefix default + defaultPrefix?: string; + }[]; + presets?: { + title: string; + source: DataTable; + default: string; + bind: { + [key: string]: string; + }; + }[]; + variables: { + name: string; + title: string; + unit: string; + defaultPrefix?: string; + // If absence, assume it's the default target var + default?: number; + + // If present and false, don't allow user to select this as output + solvable?: boolean; + + // Alternate rearrangement of the formula, to be used when calculating this. + // If missing, the main formula is used with auto derivation. + formula?: string; + + // Alternates are alternate ways of entering the exact same thing, + // like the diameter or radius. The formula for an alternate + // can use only one variable, always called v, which is the main + // variable it's an alternate of + alternates?: AlternativeVarInfo[]; + }[]; +} diff --git a/src/pages/tools/number/generic-calc/data/voltageDropInWire.ts b/src/pages/tools/number/generic-calc/data/voltageDropInWire.ts new file mode 100644 index 0000000..1fc8c72 --- /dev/null +++ b/src/pages/tools/number/generic-calc/data/voltageDropInWire.ts @@ -0,0 +1,95 @@ +import type { GenericCalcType } from './types'; +import material_electrical_properties from '../../../../../datatables/data/material_electrical_properties'; +import wire_gauge from '../../../../../datatables/data/wire_gauge'; + +const voltageDropInWire: GenericCalcType = { + icon: 'simple-icons:wire', + keywords: [ + 'voltage drop', + 'cable', + 'wire', + 'electrical', + 'resistance', + 'power loss', + 'conductor', + 'resistivity', + 'AWG', + 'gauge' + ], + shortDescription: + 'Calculate voltage drop and power loss in electrical cables based on length, material, and current', + name: 'Round trip voltage drop in cable', + path: 'cable-voltage-drop', + formula: 'x = (((p * L) / (A/10**6) ) *2) * I', + description: + 'Calculates round trip voltage and power loss in a 2 conductor cable', + longDescription: + 'This calculator helps determine the voltage drop and power loss in a two-conductor electrical cable. It takes into account the cable length, wire gauge (cross-sectional area), material resistivity, and current flow. The tool calculates the round-trip voltage drop, total resistance of the cable, and the power dissipated as heat. This is particularly useful for electrical engineers, electricians, and hobbyists when designing electrical systems to ensure voltage levels remain within acceptable limits at the load.', + presets: [ + { + title: 'Material', + source: material_electrical_properties, + default: 'Copper', + bind: { + p: 'resistivity_20c' + } + }, + + { + title: 'Wire Gauge', + source: wire_gauge, + default: '24 AWG', + bind: { + A: 'area' + } + } + ], + + extraOutputs: [ + { + title: 'Total Resistance', + formula: '((p * L) / (A/10**6))*2', + unit: 'Ω' + }, + { + title: 'Total Power Dissipated', + formula: 'I**2 * (((p * L) / (A/10**6))*2)', + unit: 'W' + } + ], + variables: [ + { + name: 'L', + title: 'Length', + unit: 'meter', + default: 1 + }, + { + name: 'A', + title: 'Wire Area', + unit: 'mm2', + default: 1 + }, + + { + name: 'I', + title: 'Current', + unit: 'A', + default: 1 + }, + { + name: 'p', + title: 'Resistivity', + unit: 'Ω/m3', + default: 1, + defaultPrefix: 'n' + }, + { + name: 'x', + title: 'Voltage Drop', + unit: 'V' + } + ] +}; + +export default voltageDropInWire; diff --git a/src/pages/tools/number/generic-calc/generic-calc.service.test.ts b/src/pages/tools/number/generic-calc/generic-calc.service.test.ts new file mode 100644 index 0000000..4152224 --- /dev/null +++ b/src/pages/tools/number/generic-calc/generic-calc.service.test.ts @@ -0,0 +1,6 @@ +import { expect, describe, it } from 'vitest'; +// import { main } from './service'; +// +// describe('generic-calc', () => { +// +// }) diff --git a/src/pages/tools/number/generic-calc/index.tsx b/src/pages/tools/number/generic-calc/index.tsx new file mode 100644 index 0000000..273f720 --- /dev/null +++ b/src/pages/tools/number/generic-calc/index.tsx @@ -0,0 +1,586 @@ +import { + Autocomplete, + Box, + MenuItem, + Radio, + Select, + Stack, + TextField, + useTheme +} from '@mui/material'; +import React, { useContext, useState } from 'react'; +import ToolContent from '@components/ToolContent'; +import { ToolComponentProps } from '@tools/defineTool'; +import NumericInputWithUnit from '@components/input/NumericInputWithUnit'; +import { UpdateField } from '@components/options/ToolOptions'; +import { InitialValuesType } from './types'; +import type { AlternativeVarInfo, GenericCalcType } from './data/types'; +import { dataTableLookup } from 'datatables'; + +import nerdamer from 'nerdamer-prime'; +import 'nerdamer-prime/Algebra'; +import 'nerdamer-prime/Solve'; +import 'nerdamer-prime/Calculus'; +import Qty from 'js-quantities'; +import { CustomSnackBarContext } from 'contexts/CustomSnackBarContext'; +import Typography from '@mui/material/Typography'; +import Grid from '@mui/material/Grid'; +import useMediaQuery from '@mui/material/useMediaQuery'; + +function numericSolveEquationFor( + equation: string, + varName: string, + variables: { [key: string]: number } +) { + let expr = nerdamer(equation); + for (const key in variables) { + expr = expr.sub(key, variables[key].toString()); + } + + let result: nerdamer.Expression | nerdamer.Expression[] = + expr.solveFor(varName); + + // Sometimes the result is an array, check for it while keeping linter happy + if ((result as unknown as nerdamer.Expression).toDecimal === undefined) { + result = (result as unknown as nerdamer.Expression[])[0]; + } + + return parseFloat( + (result as unknown as nerdamer.Expression).evaluate().toDecimal() + ); +} + +export default async function makeTool( + calcData: GenericCalcType +): Promise> { + const initialValues: InitialValuesType = { + outputVariable: '', + vars: {}, + presets: {} + }; + + return function GenericCalc({ title }: ToolComponentProps) { + const { showSnackBar } = useContext(CustomSnackBarContext); + const theme = useTheme(); + const lessThanSmall = useMediaQuery(theme.breakpoints.down('sm')); + + // For UX purposes we need to track what vars are + const [valsBoundToPreset, setValsBoundToPreset] = useState<{ + [key: string]: string; + }>({}); + + const [extraOutputs, setExtraOutputs] = useState<{ + [key: string]: number; + }>({}); + + const updateVarField = ( + name: string, + value: number, + unit: string, + values: InitialValuesType, + updateFieldFunc: UpdateField + ) => { + // Make copy + const newVars = { ...values.vars }; + newVars[name] = { + value, + unit: unit + }; + updateFieldFunc('vars', newVars); + }; + + const handleSelectedTargetChange = ( + varName: string, + updateFieldFunc: UpdateField + ) => { + updateFieldFunc('outputVariable', varName); + }; + + const handleSelectedPresetChange = ( + selection: string, + preset: string, + currentValues: InitialValuesType, + updateFieldFunc: UpdateField + ) => { + const newPresets = { ...currentValues.presets }; + newPresets[selection] = preset; + updateFieldFunc('presets', newPresets); + + // Clear old selection using setState callback pattern + setValsBoundToPreset((prevState) => { + const newState = { ...prevState }; + // Remove all keys bound to this selection + Object.keys(newState).forEach((key) => { + if (newState[key] === selection) { + delete newState[key]; + } + }); + return newState; + }); + + const selectionData = calcData.presets?.find( + (sel) => sel.title === selection + ); + + if (preset && preset != '') { + if (selectionData) { + // Create an object with the new bindings + const newBindings: { [key: string]: string } = {}; + + for (const key in selectionData.bind) { + // Add to newBindings for later state update + newBindings[key] = selection; + + if (currentValues.outputVariable === key) { + handleSelectedTargetChange('', updateFieldFunc); + } + + updateVarField( + key, + + dataTableLookup(selectionData.source, preset)[ + selectionData.bind[key] + ], + + selectionData.source.columns[selectionData.bind[key]]?.unit || '', + currentValues, + updateFieldFunc + ); + } + + // Update state with new bindings + setValsBoundToPreset((prevState) => ({ + ...prevState, + ...newBindings + })); + } else { + throw new Error( + `Preset "${preset}" is not valid for selection "${selection}"` + ); + } + } + }; + + calcData.variables.forEach((variable) => { + if (variable.solvable === undefined) { + variable.solvable = true; + } + if (variable.default === undefined) { + initialValues.vars[variable.name] = { + value: NaN, + unit: variable.unit + }; + initialValues.outputVariable = variable.name; + } else { + initialValues.vars[variable.name] = { + value: variable.default || 0, + unit: variable.unit + }; + } + }); + + calcData.presets?.forEach((selection) => { + initialValues.presets[selection.title] = selection.default; + if (selection.default == '') return; + for (const key in selection.bind) { + initialValues.vars[key] = { + value: dataTableLookup(selection.source, selection.default)[ + selection.bind[key] + ], + + unit: selection.source.columns[selection.bind[key]]?.unit || '' + }; + // We'll set this in useEffect instead of directly modifying state + } + }); + + function getAlternate( + alternateInfo: AlternativeVarInfo, + mainInfo: GenericCalcType['variables'][number], + mainValue: { + value: number; + unit: string; + } + ) { + if (isNaN(mainValue.value)) return NaN; + const canonicalValue = Qty(mainValue.value, mainValue.unit).to( + mainInfo.unit + ).scalar; + + return numericSolveEquationFor(alternateInfo.formula, 'x', { + v: canonicalValue + }); + } + + function getMainFromAlternate( + alternateInfo: AlternativeVarInfo, + mainInfo: GenericCalcType['variables'][number], + alternateValue: { + value: number; + unit: string; + } + ) { + if (isNaN(alternateValue.value)) return NaN; + const canonicalValue = Qty(alternateValue.value, alternateValue.unit).to( + alternateInfo.unit + ).scalar; + + return numericSolveEquationFor(alternateInfo.formula, 'v', { + x: canonicalValue + }); + } + + return ( + [ + ...(calcData.presets?.length + ? [ + { + title: 'Presets', + component: ( + + {calcData.presets?.map((preset) => ( + + + {preset.title} + ', + ...Object.keys(preset.source.data).sort() + ]} + sx={{ width: '80%' }} + onChange={(event, newValue) => { + handleSelectedPresetChange( + preset.title, + newValue || '', + values, + updateField + ); + }} + renderInput={(params) => ( + + )} + /> + + + ))} + + ) + } + ] + : []), + { + title: 'Variables', + component: ( + + {lessThanSmall ? ( + + Solve for + + + ) : ( + + + + + Solve For + + + + )} + {calcData.variables.map((variable) => ( + + + + + + + + {variable.title} + + + updateVarField( + variable.name, + val.value, + val.unit, + values, + updateField + ) + } + /> + + + {variable.alternates?.map((alt) => ( + + + + {alt.title} + + + + updateVarField( + variable.name, + getMainFromAlternate( + alt, + variable, + val + ), + variable.unit, + values, + updateField + ) + } + /> + + + + ))} + + + + + {!lessThanSmall && ( + + + handleSelectedTargetChange( + variable.name, + updateField + ) + } + /> + + )} + + + ))} + + ) + }, + ...(calcData.extraOutputs + ? [ + { + title: 'Extra outputs', + component: ( + + + {calcData.extraOutputs?.map((extraOutput) => ( + + + {extraOutput.title} + + + + ))} + + + ) + } + ] + : []) + ]} + compute={(values) => { + if (values.outputVariable === '') { + showSnackBar('Please select a solve for variable', 'error'); + return; + } + let expr: nerdamer.Expression | null = null; + + for (const variable of calcData.variables) { + if (variable.name === values.outputVariable) { + if (variable.formula !== undefined) { + expr = nerdamer(variable.formula); + } + } + } + + if (expr == null) { + expr = nerdamer(calcData.formula); + } + if (expr == null) { + throw new Error('No formula found'); + } + + Object.keys(values.vars).forEach((key) => { + if (key === values.outputVariable) return; + if (expr === null) { + throw new Error('Math fail'); + } + expr = expr.sub(key, values.vars[key].value.toString()); + }); + + let result: nerdamer.Expression | nerdamer.Expression[] = + expr.solveFor(values.outputVariable); + + // Sometimes the result is an array + if ( + (result as unknown as nerdamer.Expression).toDecimal === undefined + ) { + if ((result as unknown as nerdamer.Expression[])?.length < 1) { + values.vars[values.outputVariable].value = NaN; + if (calcData.extraOutputs !== undefined) { + // Update extraOutputs using setState + setExtraOutputs((prevState) => { + const newState = { ...prevState }; + for (let i = 0; i < calcData.extraOutputs!.length; i++) { + const extraOutput = calcData.extraOutputs![i]; + newState[extraOutput.title] = NaN; + } + return newState; + }); + } + throw new Error('No solution found for this input'); + } + result = (result as unknown as nerdamer.Expression[])[0]; + } + + if (result) { + if (values.vars[values.outputVariable] != undefined) { + values.vars[values.outputVariable].value = parseFloat( + (result as unknown as nerdamer.Expression) + .evaluate() + .toDecimal() + ); + } + } else { + values.vars[values.outputVariable].value = NaN; + } + + if (calcData.extraOutputs !== undefined) { + for (let i = 0; i < calcData.extraOutputs.length; i++) { + const extraOutput = calcData.extraOutputs[i]; + + let expr = nerdamer(extraOutput.formula); + + Object.keys(values.vars).forEach((key) => { + expr = expr.sub(key, values.vars[key].value.toString()); + }); + + // todo could this have multiple solutions too? + const result: nerdamer.Expression = expr.evaluate(); + + if (result) { + // Update extraOutputs state properly + setExtraOutputs((prevState) => ({ + ...prevState, + [extraOutput.title]: parseFloat(result.toDecimal()) + })); + } + } + } + }} + /> + ); + }; +} diff --git a/src/pages/tools/number/generic-calc/meta.ts b/src/pages/tools/number/generic-calc/meta.ts new file mode 100644 index 0000000..f4e3582 --- /dev/null +++ b/src/pages/tools/number/generic-calc/meta.ts @@ -0,0 +1,32 @@ +import { DefinedTool, defineTool } from '@tools/defineTool'; +import { lazy } from 'react'; +import type { GenericCalcType } from './data/types'; +import allGenericCalcs from './data/index'; + +async function importComponent(data: GenericCalcType) { + const x = await import('./index'); + return { default: await x.default(data) }; +} + +const tools: DefinedTool[] = []; + +allGenericCalcs.forEach((x) => { + async function importComponent2() { + return await importComponent(x); + } + + tools.push( + defineTool('number', { + name: x.name, + path: 'generic-calc/' + x.path, + icon: x.icon || '', + description: x.description || '', + shortDescription: x.description || '', + keywords: ['calculator', 'math', ...x.keywords], + longDescription: x.longDescription || '', + component: lazy(importComponent2) + }) + ); +}); + +export { tools }; diff --git a/src/pages/tools/number/generic-calc/types.ts b/src/pages/tools/number/generic-calc/types.ts new file mode 100644 index 0000000..28f6076 --- /dev/null +++ b/src/pages/tools/number/generic-calc/types.ts @@ -0,0 +1,14 @@ +export type InitialValuesType = { + vars: { + [key: string]: { + value: number; + unit: string; + }; + }; + + // Track preset selections + presets: { + [key: string]: string; + }; + outputVariable: string; +}; diff --git a/src/pages/tools/number/index.ts b/src/pages/tools/number/index.ts index c2ca546..f26f6fe 100644 --- a/src/pages/tools/number/index.ts +++ b/src/pages/tools/number/index.ts @@ -1,5 +1,10 @@ import { tool as numberSum } from './sum/meta'; import { tool as numberGenerate } from './generate/meta'; import { tool as numberArithmeticSequence } from './arithmetic-sequence/meta'; - -export const numberTools = [numberSum, numberGenerate, numberArithmeticSequence]; +import { tools as genericCalcTools } from './generic-calc/meta'; +export const numberTools = [ + numberSum, + numberGenerate, + numberArithmeticSequence, + ...genericCalcTools +]; diff --git a/src/tools/defineTool.tsx b/src/tools/defineTool.tsx index ace53b4..0ea3935 100644 --- a/src/tools/defineTool.tsx +++ b/src/tools/defineTool.tsx @@ -2,7 +2,7 @@ import ToolLayout from '../components/ToolLayout'; import React, { JSXElementConstructor, LazyExoticComponent } from 'react'; import { IconifyIcon } from '@iconify/react'; -interface ToolOptions { +export interface ToolMeta { path: string; component: LazyExoticComponent>; keywords: string[]; @@ -44,7 +44,7 @@ export interface ToolComponentProps { export const defineTool = ( basePath: ToolCategory, - options: ToolOptions + options: ToolMeta ): DefinedTool => { const { icon,