diff --git a/src/components/input/NumericInputWithUnit.tsx b/src/components/input/NumericInputWithUnit.tsx index 4a5990f..eb9eeed 100644 --- a/src/components/input/NumericInputWithUnit.tsx +++ b/src/components/input/NumericInputWithUnit.tsx @@ -1,8 +1,10 @@ import React, { useState, useEffect } from 'react'; import { Grid, TextField, Select, MenuItem } from '@mui/material'; +import { NumberField } from '@base-ui-components/react/number-field'; import TextFieldWithDesc from '@components/options/TextFieldWithDesc'; import Autocomplete from '@mui/material/Autocomplete'; import Qty from 'js-quantities'; +import { isNull, set } from 'lodash'; // const siPrefixes: { [key: string]: number } = { @@ -21,12 +23,18 @@ export default function NumericInputWithUnit(props: { value: { value: number; unit: string }; disabled?: boolean; disableChangingUnit?: boolean; - onOwnChange: (value: { value: number; unit: string }, ...baseProps) => void; + onOwnChange: (value: { value: number; unit: string }) => void; defaultPrefix?: string; }) { const [inputValue, setInputValue] = useState(props.value.value); const [prefix, setPrefix] = useState(props.defaultPrefix || ''); - const [unit, setUnit] = useState(props.value.unit); + + // 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); @@ -38,32 +46,48 @@ export default function NumericInputWithUnit(props: { setDisabled(props.disabled); setDisableChangingUnit(props.disableChangingUnit); }, [props.disabled, props.disableChangingUnit]); + useEffect(() => { - try { + 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(); const units = Qty.getUnits(kind); if (!units.includes(props.value.unit)) { units.push(props.value.unit); } - setUnitOptions(units); - } catch (error) { - console.error('Invalid unit kind', error); + setInputValue(props.value.value); + setUnit(props.value.unit); + setUnitKind(kind); + setUserSelectedUnit(false); + return; } - }, [props.value.unit]); - useEffect(() => { - setInputValue(props.value.value); - setUnit(props.value.unit); - }, [props.value]); + 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 handleValueChange = (val: { value: number; unit: string }) => { - const newValue = val.value; + const handleUserValueChange = (newValue: number) => { setInputValue(newValue); + if (props.onOwnChange) { try { - const qty = Qty(newValue * siPrefixes[prefix], unit).to(val.unit); - props.onOwnChange({ unit: val.unit, value: qty.scalar }); + 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); } @@ -75,39 +99,19 @@ export default function NumericInputWithUnit(props: { const newPrefixValue = siPrefixes[newPrefix]; setPrefix(newPrefix); - - // Value does not change, it is just re-formatted for display - // handleValueChange({ - // value: (inputValue * oldPrefixValue) / newPrefixValue, - // unit: unit - // }); }; - const handleUnitChange = (newUnit: string) => { + const handleUserUnitChange = (newUnit: string) => { if (!newUnit) return; const oldInputValue = inputValue; const oldUnit = unit; setUnit(newUnit); setPrefix(''); - try { - const convertedValue = Qty( - oldInputValue * siPrefixes[prefix], - oldUnit - ).to(newUnit).scalar; - setInputValue(convertedValue); - } catch (error) { - console.error('Unit conversion error', error); - } - - if (props.onOwnChange) { - try { - const qty = Qty(inputValue, unit).to(newUnit); - props.onOwnChange({ unit: newUnit, value: qty.scalar }); - } catch (error) { - console.error('Conversion error', error); - } - } + const convertedValue = Qty(oldInputValue * siPrefixes[prefix], oldUnit).to( + newUnit + ).scalar; + setInputValue(convertedValue); }; return ( @@ -119,17 +123,13 @@ export default function NumericInputWithUnit(props: { > - handleValueChange({ value: parseFloat(value), unit: unit }) - } - label="Value" + onOwnChange={(value) => handleUserValueChange(parseFloat(value))} /> @@ -140,8 +140,8 @@ export default function NumericInputWithUnit(props: { label="Prefix" title="Prefix" value={prefix} - onChange={(event, newValue) => { - handlePrefixChange(newValue?.props?.value || ''); + onChange={(evt) => { + handlePrefixChange(evt.target.value || ''); }} > {Object.keys(siPrefixes).map((key) => ( @@ -159,8 +159,9 @@ export default function NumericInputWithUnit(props: { label="Unit" title="Unit" value={unit} - onChange={(event, newValue) => { - handleUnitChange(newValue?.props?.value || ''); + onChange={(event) => { + setUserSelectedUnit(true); + handleUserUnitChange(event.target.value || ''); }} > {unitOptions.map((key) => ( diff --git a/src/pages/tools/number/generic-calc/data/area_volume.ts b/src/pages/tools/number/generic-calc/data/area_volume.ts new file mode 100644 index 0000000..487b80a --- /dev/null +++ b/src/pages/tools/number/generic-calc/data/area_volume.ts @@ -0,0 +1,50 @@ +import type { GenericCalcType } from './types'; + +export const areaSphere: GenericCalcType = { + title: 'Area of a Sphere', + name: 'area-sphere', + description: 'Area of a Sphere', + formula: 'A = 4 * pi * r**2', + selections: [], + variables: [ + { + name: 'A', + title: 'Area', + unit: 'mm2' + }, + { + name: 'r', + title: 'Radius', + unit: 'mm', + default: 1 + } + ] +}; + +export const volumeSphere: GenericCalcType = { + title: 'Volume of a Sphere', + name: 'volume-sphere', + description: 'Volume of a Sphere', + formula: 'v = (4/3) * pi * r**3', + selections: [], + variables: [ + { + name: 'v', + title: 'Volume', + unit: 'mm3' + }, + { + name: 'r', + title: 'Radius', + unit: 'mm', + default: 1, + alternates: [ + { + title: 'Diameter', + formula: 'x = 2 * v', + unit: 'mm' + } + ] + } + ] +}; diff --git a/src/pages/tools/number/generic-calc/data/index.ts b/src/pages/tools/number/generic-calc/data/index.ts index 9249a30..3b27f80 100644 --- a/src/pages/tools/number/generic-calc/data/index.ts +++ b/src/pages/tools/number/generic-calc/data/index.ts @@ -1,4 +1,5 @@ import ohmslaw from './ohms_law'; import voltagedropinwire from './wire_voltage_drop'; +import { areaSphere, volumeSphere } from './area_volume'; -export default [ohmslaw, voltagedropinwire]; +export default [ohmslaw, voltagedropinwire, areaSphere, volumeSphere]; diff --git a/src/pages/tools/number/generic-calc/data/types.ts b/src/pages/tools/number/generic-calc/data/types.ts index 25ded86..9742e5b 100644 --- a/src/pages/tools/number/generic-calc/data/types.ts +++ b/src/pages/tools/number/generic-calc/data/types.ts @@ -1,3 +1,9 @@ +export interface AlternativeVarInfo { + title: string; + unit: string; + defaultPrefix?: string; + formula: string; +} export interface GenericCalcType { title: string; name: string; @@ -26,5 +32,14 @@ export interface GenericCalcType { 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; + + // 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/index.tsx b/src/pages/tools/number/generic-calc/index.tsx index 1a559e2..c732114 100644 --- a/src/pages/tools/number/generic-calc/index.tsx +++ b/src/pages/tools/number/generic-calc/index.tsx @@ -16,14 +16,38 @@ import ToolTextResult from '@components/result/ToolTextResult'; import NumericInputWithUnit from '@components/input/NumericInputWithUnit'; import { UpdateField } from '@components/options/ToolOptions'; import { InitialValuesType } from './types'; -import type { GenericCalcType } from './data/types'; +import type { AlternativeVarInfo, GenericCalcType } from './data/types'; import type { DataTable } from 'datatables'; import { getDataTable, dataTableLookup } from 'datatables'; -import nerdamer from 'nerdamer'; -import 'nerdamer/Algebra'; -import 'nerdamer/Solve'; -import 'nerdamer/Calculus'; +import nerdamer from 'nerdamer-prime'; +import 'nerdamer-prime/Algebra'; +import 'nerdamer-prime/Solve'; +import 'nerdamer-prime/Calculus'; +import Qty from 'js-quantities'; + +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 @@ -42,7 +66,15 @@ export default async function makeTool( return function GenericCalc({ title }: ToolComponentProps) { const [result, setResult] = useState(''); - const [shortResult, setShortResult] = useState(''); + + const [alternatesByVariable, setAlternatesByVariable] = useState<{ + [key: string]: { + value: { + value: number; + unit: string; + }; + }[]; + }>({}); // For UX purposes we need to track what vars are const [valsBoundToPreset, setValsBoundToPreset] = useState<{ @@ -128,6 +160,9 @@ export default async function makeTool( }; calcData.variables.forEach((variable) => { + if (variable.solvable === undefined) { + variable.solvable = true; + } if (variable.default === undefined) { initialValues.vars[variable.name] = { value: NaN, @@ -160,18 +195,72 @@ export default async function makeTool( } }); + 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 + }); + } + + calcData.variables.forEach((variable) => { + if (variable.alternates) { + variable.alternates.forEach((alt) => { + const altValue = getAlternate( + alt, + variable, + initialValues.vars[variable.name] + ); + if (alternatesByVariable[variable.name] === undefined) { + alternatesByVariable[variable.name] = []; + } + + alternatesByVariable[variable.name].push({ + value: { value: altValue, unit: variable.unit } + }); + }); + } + }); + return ( - } initialValues={initialValues} toolInfo={{ - title: 'Common Equations', + title: calcData.title, description: - 'Common mathematical equations that can be used in calculations.' + (calcData.description || '') + + ' Generated from formula: ' + + calcData.formula }} getGroups={({ values, updateField }) => [ { @@ -227,7 +316,6 @@ export default async function makeTool( - Variable Value Solve For @@ -235,38 +323,80 @@ export default async function makeTool( {calcData.variables.map((variable) => ( - {variable.title} - - updateVarField( - variable.name, - parseFloat(val.value), - val.unit, - values, - updateField - ) - } - type="number" - /> +
+ + {variable.title} + + + updateVarField( + variable.name, + val.value, + val.unit, + values, + updateField + ) + } + type="number" + /> + + + + {variable.alternates?.map((alt) => ( + + {alt.title} + + + updateVarField( + variable.name, + getMainFromAlternate(alt, variable, val), + variable.unit, + values, + updateField + ) + } + > + + + ))} +
@@ -274,7 +404,8 @@ export default async function makeTool( value={variable.name} checked={values.outputVariable === variable.name} disabled={ - valsBoundToPreset[variable.name] !== undefined + valsBoundToPreset[variable.name] !== undefined || + variable.solvable === false } onClick={() => handleSelectedTargetChange( @@ -292,7 +423,6 @@ export default async function makeTool( {extraOutput.title}