|
| 1 | +import React, { useState } from 'react'; |
| 2 | +import { |
| 3 | + type Variable, |
| 4 | + type RowData, |
| 5 | + type CalculationResult, |
| 6 | + sanitizeNumberInput, |
| 7 | + makeDefaultValues, |
| 8 | + useDarkMode, |
| 9 | + renderKatexInline, |
| 10 | + evaluateExpression, |
| 11 | + buildReadableExpression, |
| 12 | + buildOptionDisplayValues, |
| 13 | + resolveRawValue, |
| 14 | + initializeRows, |
| 15 | + createRow, |
| 16 | + evaluateRows, |
| 17 | + buildSumReadable, |
| 18 | + roundResult, |
| 19 | + useInitialCalculation, |
| 20 | + VariableInput, |
| 21 | + RowGrid, |
| 22 | + ResultSection, |
| 23 | + CalculatorWrapper, |
| 24 | + getLabelColor, |
| 25 | + getSubtextColor, |
| 26 | +} from './calculatorUtils'; |
| 27 | + |
| 28 | +interface CompoundCalculatorProps { |
| 29 | + heading?: string; |
| 30 | + formuLatex: string; |
| 31 | + variables: Variable[]; |
| 32 | + rowFormula: string; |
| 33 | + resultFormula: string; |
| 34 | + defaultRows?: Array<Record<string, number>>; |
| 35 | +} |
| 36 | + |
| 37 | +export const CompoundCalculator: React.FC<CompoundCalculatorProps> = ({ |
| 38 | + heading, |
| 39 | + formuLatex, |
| 40 | + variables, |
| 41 | + rowFormula, |
| 42 | + resultFormula, |
| 43 | + defaultRows, |
| 44 | +}) => { |
| 45 | + const rowVariables = variables.filter(v => !v.global); |
| 46 | + const globalVariables = variables.filter(v => v.global); |
| 47 | + |
| 48 | + const [rows, setRows] = useState<RowData[]>(() => initializeRows(rowVariables, defaultRows)); |
| 49 | + const [globalValues, setGlobalValues] = useState<Record<string, number | string>>(() => |
| 50 | + makeDefaultValues(globalVariables) |
| 51 | + ); |
| 52 | + const [result, setResult] = useState<CalculationResult | null>(null); |
| 53 | + const isDark = useDarkMode(); |
| 54 | + |
| 55 | + const updateRowValue = (rowId: string, variableName: string, value: string) => { |
| 56 | + setRows(prev => |
| 57 | + prev.map(r => |
| 58 | + r.id === rowId |
| 59 | + ? { ...r, values: { ...r.values, [variableName]: sanitizeNumberInput(value) } } |
| 60 | + : r |
| 61 | + ) |
| 62 | + ); |
| 63 | + }; |
| 64 | + |
| 65 | + const updateGlobalValue = (variableName: string, value: string) => { |
| 66 | + setGlobalValues(prev => ({ ...prev, [variableName]: sanitizeNumberInput(value) })); |
| 67 | + }; |
| 68 | + |
| 69 | + const addRow = () => setRows(prev => [...prev, createRow(rowVariables)]); |
| 70 | + const removeRow = (id: string) => setRows(prev => prev.filter(r => r.id !== id)); |
| 71 | + |
| 72 | + const handleCalculate = () => { |
| 73 | + // Evaluate per-row formula and sum |
| 74 | + const evalResult = evaluateRows(rows, rowVariables, rowFormula); |
| 75 | + if ('error' in evalResult) { |
| 76 | + setResult({ display: '', value: null, error: evalResult.error }); |
| 77 | + return; |
| 78 | + } |
| 79 | + const sumValue = evalResult.results.reduce((s, r) => s + r.value, 0); |
| 80 | + |
| 81 | + // Resolve global variable values |
| 82 | + const resolvedGlobals: Record<string, number> = {}; |
| 83 | + for (const v of globalVariables) { |
| 84 | + resolvedGlobals[v.variableName] = resolveRawValue(globalValues[v.variableName], v.variableValue ?? 1); |
| 85 | + } |
| 86 | + |
| 87 | + // Evaluate the outer formula with _sum + globals, build display |
| 88 | + try { |
| 89 | + const outerValues = { _sum: sumValue, ...resolvedGlobals }; |
| 90 | + const finalValue = evaluateExpression(resultFormula, outerValues); |
| 91 | + |
| 92 | + if (!isFinite(finalValue) || isNaN(finalValue)) { |
| 93 | + setResult({ display: '', value: null, error: 'No result: division by zero' }); |
| 94 | + return; |
| 95 | + } |
| 96 | + |
| 97 | + const rounded = roundResult(finalValue); |
| 98 | + const sumReadable = buildSumReadable(evalResult.results); |
| 99 | + const globalDisplayValues = buildOptionDisplayValues(globalVariables, resolvedGlobals); |
| 100 | + const outerDisplayValues: Record<string, string> = { _sum: sumReadable, ...globalDisplayValues }; |
| 101 | + const display = buildReadableExpression(resultFormula, outerValues, outerDisplayValues) + ` = ${rounded}`; |
| 102 | + |
| 103 | + setResult({ display, value: rounded, error: null }); |
| 104 | + } catch { |
| 105 | + setResult({ display: '', value: null, error: 'Calculation error' }); |
| 106 | + } |
| 107 | + }; |
| 108 | + |
| 109 | + useInitialCalculation(handleCalculate); |
| 110 | + |
| 111 | + return ( |
| 112 | + <CalculatorWrapper heading={heading} formuLatex={formuLatex} isDark={isDark}> |
| 113 | + <div className="mb-6"> |
| 114 | + <RowGrid |
| 115 | + rows={rows} |
| 116 | + variables={rowVariables} |
| 117 | + isDark={isDark} |
| 118 | + onUpdateValue={updateRowValue} |
| 119 | + onAddRow={addRow} |
| 120 | + onRemoveRow={removeRow} |
| 121 | + /> |
| 122 | + |
| 123 | + {/* Global variable inputs */} |
| 124 | + <div |
| 125 | + className="mt-4 pt-4 border-t space-y-3" |
| 126 | + style={{ borderColor: isDark ? 'rgba(63, 63, 70, 0.5)' : '#e5e7eb' }} |
| 127 | + > |
| 128 | + {globalVariables.map(v => ( |
| 129 | + <div key={v.variableName} className="flex items-center justify-center gap-3"> |
| 130 | + <VariableInput |
| 131 | + variable={v} |
| 132 | + value={globalValues[v.variableName]} |
| 133 | + onChange={(val) => updateGlobalValue(v.variableName, val)} |
| 134 | + isDark={isDark} |
| 135 | + className="w-24" |
| 136 | + /> |
| 137 | + <span |
| 138 | + className="calculator-formula text-sm font-medium shrink-0" |
| 139 | + style={{ color: getLabelColor(isDark) }} |
| 140 | + dangerouslySetInnerHTML={{ __html: renderKatexInline(v.nameInTheFormula) }} |
| 141 | + /> |
| 142 | + <span className="text-sm" style={{ color: getSubtextColor(isDark) }}> |
| 143 | + {v.variableDescription} |
| 144 | + </span> |
| 145 | + </div> |
| 146 | + ))} |
| 147 | + </div> |
| 148 | + </div> |
| 149 | + <ResultSection result={result} isDark={isDark} onCalculate={handleCalculate} /> |
| 150 | + </CalculatorWrapper> |
| 151 | + ); |
| 152 | +}; |
0 commit comments