Merge pull request #89 from EternityForest/generic-calc

WIP: Automatic generation of simple calculators from equations
This commit is contained in:
Ibrahima G. Coulibaly
2025-04-17 08:12:35 +01:00
committed by GitHub
22 changed files with 1271 additions and 19 deletions

21
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -40,7 +40,7 @@ const FormikListenerComponent = <T,>({
interface ToolContentProps<T, I> extends ToolComponentProps {
inputComponent?: ReactNode;
resultComponent: ReactNode;
resultComponent?: ReactNode;
renderCustomInput?: (
values: T,
setFieldValue: (fieldName: string, value: any) => void
@@ -57,6 +57,7 @@ interface ToolContentProps<T, I> extends ToolComponentProps {
setInput?: React.Dispatch<React.SetStateAction<I>>;
validationSchema?: any;
onValuesChange?: (values: T) => void;
verticalGroups?: boolean;
}
export default function ToolContent<T extends FormikValues, I>({
@@ -72,7 +73,8 @@ export default function ToolContent<T extends FormikValues, I>({
setInput,
validationSchema,
renderCustomInput,
onValuesChange
onValuesChange,
verticalGroups
}: ToolContentProps<T, I>) {
return (
<Box>
@@ -97,7 +99,7 @@ export default function ToolContent<T extends FormikValues, I>({
input={input}
onValuesChange={onValuesChange}
/>
<ToolOptions getGroups={getGroups} />
<ToolOptions getGroups={getGroups} vertical={verticalGroups} />
{toolInfo && toolInfo.title && toolInfo.description && (
<ToolInfo

View File

@@ -6,18 +6,20 @@ export default function ToolInputAndResult({
result
}: {
input?: ReactNode;
result: ReactNode;
result?: ReactNode;
}) {
return (
<Grid id="tool" container spacing={2}>
{input && (
<Grid item xs={12} md={6}>
{input}
if (input || result) {
return (
<Grid id="tool" container spacing={2}>
{input && (
<Grid item xs={12} md={6}>
{input}
</Grid>
)}
<Grid item xs={12} md={input ? 6 : 12}>
{result}
</Grid>
)}
<Grid item xs={12} md={input ? 6 : 12}>
{result}
</Grid>
</Grid>
);
);
}
}

View File

@@ -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<string[]>([]);
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 (
<Grid container spacing={2} alignItems="center">
<Grid item xs={12} md={4}>
<TextFieldWithDesc
disabled={disabled}
type="number"
fullWidth
sx={{ width: { xs: '75%', sm: '80%', md: '90%' } }}
value={(inputValue / siPrefixes[prefix])
.toFixed(9)
.replace(/(\d*\.\d+?)0+$/, '$1')}
onOwnChange={(value) => handleUserValueChange(parseFloat(value))}
/>
</Grid>
<Grid item xs={12} md={3}>
<Select
fullWidth
disabled={disableChangingUnit}
value={prefix}
sx={{ width: { xs: '75%', sm: '80%', md: '90%' } }}
onChange={(evt) => {
handlePrefixChange(evt.target.value || '');
}}
>
{Object.keys(siPrefixes).map((key) => (
<MenuItem key={key} value={key}>
{key}
</MenuItem>
))}
</Select>
</Grid>
<Grid item xs={12} md={5}>
<Select
fullWidth
disabled={disableChangingUnit}
placeholder={'Unit'}
sx={{ width: { xs: '75%', sm: '80%', md: '90%' } }}
value={unit}
onChange={(event) => {
setUserSelectedUnit(true);
handleUserUnitChange(event.target.value || '');
}}
>
{unitOptions.map((key) => (
<MenuItem key={key} value={key}>
{key}
</MenuItem>
))}
</Select>
</Grid>
</Grid>
);
}

View File

@@ -13,10 +13,12 @@ export type GetGroupsType<T> = (
export default function ToolOptions<T extends FormikValues>({
children,
getGroups
getGroups,
vertical
}: {
children?: ReactNode;
getGroups: GetGroupsType<T> | null;
vertical?: boolean;
}) {
const theme = useTheme();
const formikContext = useFormikContext<T>();
@@ -49,6 +51,7 @@ export default function ToolOptions<T extends FormikValues>({
<Stack direction={'row'} spacing={2}>
<ToolOptionGroups
groups={getGroups({ ...formikContext, updateField }) ?? []}
vertical={vertical}
/>
{children}
</Stack>

View File

@@ -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
}
}
};

View File

@@ -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;

8
src/datatables/index.ts Normal file
View File

@@ -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 };

17
src/datatables/types.ts Normal file
View File

@@ -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;
};
};
}

View File

@@ -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];

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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<ToolMeta, 'component'> {
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[];
}[];
}

View File

@@ -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;

View File

@@ -0,0 +1,6 @@
import { expect, describe, it } from 'vitest';
// import { main } from './service';
//
// describe('generic-calc', () => {
//
// })

View File

@@ -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<React.JSXElementConstructor<ToolComponentProps>> {
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<InitialValuesType>
) => {
// Make copy
const newVars = { ...values.vars };
newVars[name] = {
value,
unit: unit
};
updateFieldFunc('vars', newVars);
};
const handleSelectedTargetChange = (
varName: string,
updateFieldFunc: UpdateField<InitialValuesType>
) => {
updateFieldFunc('outputVariable', varName);
};
const handleSelectedPresetChange = (
selection: string,
preset: string,
currentValues: InitialValuesType,
updateFieldFunc: UpdateField<InitialValuesType>
) => {
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 != '<custom>') {
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 == '<custom>') 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 (
<ToolContent
title={title}
inputComponent={null}
initialValues={initialValues}
toolInfo={{
title: calcData.name,
description:
(calcData.description || '') +
' Generated from formula: ' +
calcData.formula
}}
verticalGroups
getGroups={({ values, updateField }) => [
...(calcData.presets?.length
? [
{
title: 'Presets',
component: (
<Grid container spacing={2} maxWidth={500}>
{calcData.presets?.map((preset) => (
<Grid item xs={12} key={preset.title}>
<Stack
direction={{ xs: 'column', md: 'row' }}
spacing={2}
alignItems={'center'}
justifyContent={'space-between'}
>
<Typography>{preset.title}</Typography>
<Autocomplete
disablePortal
id="combo-box-demo"
value={values.presets[preset.title]}
options={[
'<custom>',
...Object.keys(preset.source.data).sort()
]}
sx={{ width: '80%' }}
onChange={(event, newValue) => {
handleSelectedPresetChange(
preset.title,
newValue || '',
values,
updateField
);
}}
renderInput={(params) => (
<TextField {...params} label="Preset" />
)}
/>
</Stack>
</Grid>
))}
</Grid>
)
}
]
: []),
{
title: 'Variables',
component: (
<Box>
{lessThanSmall ? (
<Stack
direction={'column'}
spacing={2}
alignItems={'center'}
justifyContent={'space-between'}
>
<Typography>Solve for</Typography>
<Select
sx={{ width: '80%' }}
fullWidth
value={values.outputVariable}
onChange={(event) =>
handleSelectedTargetChange(
event.target.value,
updateField
)
}
>
{calcData.variables.map((variable) => (
<MenuItem
disabled={
valsBoundToPreset[variable.name] !== undefined ||
variable.solvable === false
}
key={variable.name}
value={variable.name}
>
{variable.title}
</MenuItem>
))}
</Select>
</Stack>
) : (
<Grid container spacing={2} sx={{ mb: 2 }}>
<Grid item xs={10}></Grid>
<Grid item xs={2}>
<Typography fontWeight="bold" align="center">
Solve For
</Typography>
</Grid>
</Grid>
)}
{calcData.variables.map((variable) => (
<Box
key={variable.name}
sx={{
my: 3,
p: 1,
borderRadius: 1
}}
>
<Grid container spacing={2} alignItems="center">
<Grid item xs={lessThanSmall ? 12 : 10}>
<Box>
<Stack spacing={2}>
<Stack
direction={{ xs: 'column', md: 'row' }}
spacing={2}
alignItems="center"
>
<Typography sx={{ minWidth: '8%' }}>
{variable.title}
</Typography>
<NumericInputWithUnit
defaultPrefix={variable.defaultPrefix}
value={values.vars[variable.name]}
disabled={
values.outputVariable === variable.name ||
valsBoundToPreset[variable.name] !== undefined
}
disableChangingUnit={
valsBoundToPreset[variable.name] !== undefined
}
onOwnChange={(val) =>
updateVarField(
variable.name,
val.value,
val.unit,
values,
updateField
)
}
/>
</Stack>
{variable.alternates?.map((alt) => (
<Box key={alt.title}>
<Stack
direction="row"
spacing={2}
alignItems="center"
>
<Typography sx={{ minWidth: '8%' }}>
{alt.title}
</Typography>
<Box sx={{ flexGrow: 1 }}>
<NumericInputWithUnit
key={alt.title}
defaultPrefix={alt.defaultPrefix || ''}
value={{
value:
getAlternate(
alt,
variable,
values.vars[variable.name]
) || NaN,
unit: alt.unit || ''
}}
disabled={
values.outputVariable ===
variable.name ||
valsBoundToPreset[variable.name] !==
undefined
}
disableChangingUnit={
valsBoundToPreset[variable.name] !==
undefined
}
onOwnChange={(val) =>
updateVarField(
variable.name,
getMainFromAlternate(
alt,
variable,
val
),
variable.unit,
values,
updateField
)
}
/>
</Box>
</Stack>
</Box>
))}
</Stack>
</Box>
</Grid>
{!lessThanSmall && (
<Grid
item
xs={2}
sx={{ display: 'flex', justifyContent: 'center' }}
>
<Radio
value={variable.name}
checked={values.outputVariable === variable.name}
disabled={
valsBoundToPreset[variable.name] !== undefined ||
variable.solvable === false
}
onClick={() =>
handleSelectedTargetChange(
variable.name,
updateField
)
}
/>
</Grid>
)}
</Grid>
</Box>
))}
</Box>
)
},
...(calcData.extraOutputs
? [
{
title: 'Extra outputs',
component: (
<Box>
<Grid container spacing={2}>
{calcData.extraOutputs?.map((extraOutput) => (
<Grid item xs={12} key={extraOutput.title}>
<Stack spacing={1}>
<Typography>{extraOutput.title}</Typography>
<NumericInputWithUnit
disabled={true}
defaultPrefix={extraOutput.defaultPrefix}
value={{
value: extraOutputs[extraOutput.title],
unit: extraOutput.unit
}}
/>
</Stack>
</Grid>
))}
</Grid>
</Box>
)
}
]
: [])
]}
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())
}));
}
}
}
}}
/>
);
};
}

View File

@@ -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 };

View File

@@ -0,0 +1,14 @@
export type InitialValuesType = {
vars: {
[key: string]: {
value: number;
unit: string;
};
};
// Track preset selections
presets: {
[key: string]: string;
};
outputVariable: string;
};

View File

@@ -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
];

View File

@@ -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<JSXElementConstructor<ToolComponentProps>>;
keywords: string[];
@@ -44,7 +44,7 @@ export interface ToolComponentProps {
export const defineTool = (
basePath: ToolCategory,
options: ToolOptions
options: ToolMeta
): DefinedTool => {
const {
icon,