diff --git a/package.json b/package.json index e360d81..82ccbd4 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "ai": "^6.0.168", "echarts": "^6.0.0", "echarts-for-react": "^3.0.6", - "gaussformula": "0.1.7", + "gaussformula": "file:../gaussformula/gaussformula-0.1.7.tgz", "react": "^19.2.5", "react-dom": "^19.2.5", "zod": "^4.3.6" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eeabfc1..d82a6ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,8 +27,8 @@ importers: specifier: ^3.0.6 version: 3.0.6(echarts@6.0.0)(react@19.2.5) gaussformula: - specifier: 0.1.7 - version: 0.1.7 + specifier: file:../gaussformula/gaussformula-0.1.7.tgz + version: file:../gaussformula/gaussformula-0.1.7.tgz react: specifier: ^19.2.5 version: 19.2.5 @@ -1002,8 +1002,9 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - gaussformula@0.1.7: - resolution: {integrity: sha512-ec1MePBsFy39uFnlku8QiwoCWSfvFYqaj8MRTLfGvx1WprLJ1FPn1EltXIQum6ltuSEoS3xLDEq/JvZ3rdQwJg==} + gaussformula@file:../gaussformula/gaussformula-0.1.7.tgz: + resolution: {integrity: sha512-uUqiqmcH539hPaKwUYedq2RbFI1yiAlrGSFjEJqjVHDhlk9pi5F5qIZOfhP/WXr8wJ1NjIrS3uqY8evL8H1VXQ==, tarball: file:../gaussformula/gaussformula-0.1.7.tgz} + version: 0.1.7 gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} @@ -2362,7 +2363,7 @@ snapshots: fsevents@2.3.3: optional: true - gaussformula@0.1.7: + gaussformula@file:../gaussformula/gaussformula-0.1.7.tgz: dependencies: chevrotain: 6.5.0 tiny-emitter: 2.1.0 diff --git a/src/components/spreadsheet/CellRenderer.tsx b/src/components/spreadsheet/CellRenderer.tsx index 803fa14..ca140b2 100644 --- a/src/components/spreadsheet/CellRenderer.tsx +++ b/src/components/spreadsheet/CellRenderer.tsx @@ -1,9 +1,4 @@ -import React, { - useCallback, - useMemo, - useRef, - useState, -} from "react"; +import React, { useCallback, useMemo, useRef, useState } from "react"; import { getSamplesFromDistribution, isDistributionValue, @@ -23,6 +18,40 @@ import type { DistributionParams } from "../charts/distributionChartOptions"; import { SpreadsheetContext } from "../../context/SpreadsheetContext"; import styles from "./CellRenderer.module.css"; +interface CellEditInputProps { + initialValue: string; + onCommit: (value: string) => void; + onCancel: () => void; + ariaLabel: string; +} + +// Keeps the in-progress edit value in local state so typing does not +// re-render the parent Spreadsheet (and therefore every other cell) on +// every keystroke. This component mounts when editing begins and unmounts +// when it ends, so `initialValue` is captured fresh each edit session. +const CellEditInput: React.FC = ({ + initialValue, + onCommit, + onCancel, + ariaLabel, +}) => { + const [value, setValue] = useState(initialValue); + return ( + setValue(e.target.value)} + onBlur={() => onCommit(value)} + onKeyDown={(e) => { + if (e.key === "Enter") onCommit(value); + if (e.key === "Escape") onCancel(); + }} + className={styles.editInput} + aria-label={ariaLabel} + /> + ); +}; + interface CellRendererProps { row: number; col: number; @@ -38,7 +67,7 @@ interface CellRendererProps { onStopEdit?: () => void; } -export const CellRenderer: React.FC = ({ +const CellRendererComponent: React.FC = ({ row, col, value, @@ -48,7 +77,6 @@ export const CellRenderer: React.FC = ({ isSelected = false, isEditing = false, editValue = "", - onEditValueChange, onRequestEdit, onStopEdit, }) => { @@ -114,11 +142,6 @@ export const CellRenderer: React.FC = ({ onClick?.(); }; - const handleBlur = () => { - onEdit(row, col, editValue); - onStopEdit?.(); - }; - const handleChartHintClick = (e: React.MouseEvent) => { e.stopPropagation(); if (!cellRef.current) return; @@ -187,27 +210,20 @@ export const CellRenderer: React.FC = ({ left, }); - if ( - isDistributionValue(value) || - isSampledDistributionValue(value) - ) { + if (isDistributionValue(value) || isSampledDistributionValue(value)) { setShowPlot(true); } } }; const isDistribution = - isDistributionValue(value) || - isSampledDistributionValue(value); + isDistributionValue(value) || isSampledDistributionValue(value); const leadingIcon = useMemo(() => { - if ( - isDistributionValue(value) || - isSampledDistributionValue(value) - ) { + if (isDistributionValue(value) || isSampledDistributionValue(value)) { const sparkType = isSampledDistributionValue(value) ? ("sampled" as const) - : explicitDistribution?.kind ?? "normal"; + : (explicitDistribution?.kind ?? "normal"); const sparkColor = distColors?.accent ?? "var(--text-muted)"; return ; } @@ -219,10 +235,7 @@ export const CellRenderer: React.FC = ({ return null; }, [value, explicitDistribution, distColors]); - const displayValue = useMemo( - () => formatCellValue(value), - [value], - ); + const displayValue = useMemo(() => formatCellValue(value), [value]); const cellClassName = [ styles.cell, @@ -254,17 +267,14 @@ export const CellRenderer: React.FC = ({ }} > {isEditing ? ( - onEditValueChange?.(e.target.value)} - onBlur={handleBlur} - onKeyDown={(e) => { - if (e.key === "Enter") handleBlur(); - if (e.key === "Escape") onStopEdit?.(); + { + onEdit(row, col, committed); + onStopEdit?.(); }} - className={styles.editInput} - aria-label={`Cell ${String.fromCharCode(65 + col)}${row + 1}`} + onCancel={() => onStopEdit?.()} + ariaLabel={`Cell ${String.fromCharCode(65 + col)}${row + 1}`} /> ) : ( <> @@ -381,3 +391,7 @@ export const CellRenderer: React.FC = ({ ); }; + +// Memoized so cells whose props didn't change skip re-rendering when +// unrelated cells update (e.g. selection changes elsewhere in the grid). +export const CellRenderer = React.memo(CellRendererComponent); diff --git a/src/components/spreadsheet/Spreadsheet.tsx b/src/components/spreadsheet/Spreadsheet.tsx index 05ff7e8..14758e1 100644 --- a/src/components/spreadsheet/Spreadsheet.tsx +++ b/src/components/spreadsheet/Spreadsheet.tsx @@ -164,13 +164,6 @@ export const Spreadsheet: React.FC = ({ ? editingCell.value : "" } - onEditValueChange={(value) => { - setEditingCell((current) => - current?.row === rowIndex && current?.col === colIndex - ? { ...current, value } - : current, - ); - }} onRequestEdit={(row, col, value) => setEditingCell({ row, col, value }) } @@ -274,8 +267,8 @@ export const Spreadsheet: React.FC = ({

Start building your model

- Type values, formulas, or uncertainty like N.CI(10, 20, 0.95) to - add uncertainty + Type values, formulas, or uncertainty like{" "} + N.CI(10, 20, 0.95) to add uncertainty

diff --git a/src/components/spreadsheet/cellFormatting.tsx b/src/components/spreadsheet/cellFormatting.tsx index 6eb164d..dfafe61 100644 --- a/src/components/spreadsheet/cellFormatting.tsx +++ b/src/components/spreadsheet/cellFormatting.tsx @@ -4,6 +4,7 @@ import React from "react"; import { confidenceLevelToFormulaArgument, + formatDistributionFormulaNumber, isDistributionValue, isSampledDistributionValue, parseDistributionValue, @@ -145,28 +146,51 @@ export const formatCellEditValue = (val: CellValue): string => { const distribution = parseDistributionValue(val); if (distribution) { switch (distribution.kind) { - case "normal": + case "normal": { if ( distribution.source === "ci" && distribution.lower !== undefined && distribution.upper !== undefined && distribution.confidenceLevel !== undefined ) { - return `N.CI(${distribution.lower.toFixed(2)}, ${distribution.upper.toFixed(2)}, ${confidenceLevelToFormulaArgument(distribution.confidenceLevel).toFixed(2)})`; + const lower = formatDistributionFormulaNumber(distribution.lower); + const upper = formatDistributionFormulaNumber(distribution.upper); + const confidence = formatDistributionFormulaNumber( + confidenceLevelToFormulaArgument(distribution.confidenceLevel), + ); + return `N.CI(${lower}, ${upper}, ${confidence})`; } - return `N(${(distribution.mean ?? 0).toFixed(2)}, ${(distribution.variance ?? 0).toFixed(2)})`; - case "lognormal": + const mean = formatDistributionFormulaNumber(distribution.mean ?? 0); + const variance = formatDistributionFormulaNumber( + distribution.variance ?? 0, + ); + return `N(${mean}, ${variance})`; + } + case "lognormal": { if ( distribution.source === "ci" && distribution.lower !== undefined && distribution.upper !== undefined && distribution.confidenceLevel !== undefined ) { - return `LN.CI(${distribution.lower.toFixed(2)}, ${distribution.upper.toFixed(2)}, ${confidenceLevelToFormulaArgument(distribution.confidenceLevel).toFixed(2)})`; + const lower = formatDistributionFormulaNumber(distribution.lower); + const upper = formatDistributionFormulaNumber(distribution.upper); + const confidence = formatDistributionFormulaNumber( + confidenceLevelToFormulaArgument(distribution.confidenceLevel), + ); + return `LN.CI(${lower}, ${upper}, ${confidence})`; } - return `LN(${(distribution.mu ?? 0).toFixed(2)}, ${(distribution.sigma ?? 0).toFixed(2)})`; - case "uniform": - return `U(${(distribution.min ?? 0).toFixed(2)}, ${(distribution.max ?? 0).toFixed(2)})`; + const mu = formatDistributionFormulaNumber(distribution.mu ?? 0); + const sigma = formatDistributionFormulaNumber( + distribution.sigma ?? 0, + ); + return `LN(${mu}, ${sigma})`; + } + case "uniform": { + const min = formatDistributionFormulaNumber(distribution.min ?? 0); + const max = formatDistributionFormulaNumber(distribution.max ?? 0); + return `U(${min}, ${max})`; + } } } } diff --git a/src/hooks/useFormulaBar.ts b/src/hooks/useFormulaBar.ts index 71c2de6..96441eb 100644 --- a/src/hooks/useFormulaBar.ts +++ b/src/hooks/useFormulaBar.ts @@ -2,6 +2,7 @@ import { useCallback, useContext, useEffect, useState } from "react"; import { SpreadsheetContext } from "../context/SpreadsheetContext"; import { confidenceLevelToFormulaArgument, + formatDistributionFormulaNumber, parseDistributionValue, parseSampledDistributionValue, } from "../utils/distribution/distributionUtils"; @@ -19,28 +20,51 @@ export const formatFormulaBarCellValue = (value: unknown): string => { const distribution = parseDistributionValue(value); if (distribution) { switch (distribution.kind) { - case "normal": + case "normal": { if ( distribution.source === "ci" && distribution.lower !== undefined && distribution.upper !== undefined && distribution.confidenceLevel !== undefined ) { - return `N.CI(${distribution.lower.toFixed(2)}, ${distribution.upper.toFixed(2)}, ${confidenceLevelToFormulaArgument(distribution.confidenceLevel).toFixed(2)})`; + const lower = formatDistributionFormulaNumber(distribution.lower); + const upper = formatDistributionFormulaNumber(distribution.upper); + const confidence = formatDistributionFormulaNumber( + confidenceLevelToFormulaArgument(distribution.confidenceLevel), + ); + return `N.CI(${lower}, ${upper}, ${confidence})`; } - return `N(${(distribution.mean ?? 0).toFixed(2)}, ${(distribution.variance ?? 0).toFixed(2)})`; - case "lognormal": + const mean = formatDistributionFormulaNumber(distribution.mean ?? 0); + const variance = formatDistributionFormulaNumber( + distribution.variance ?? 0, + ); + return `N(${mean}, ${variance})`; + } + case "lognormal": { if ( distribution.source === "ci" && distribution.lower !== undefined && distribution.upper !== undefined && distribution.confidenceLevel !== undefined ) { - return `LN.CI(${distribution.lower.toFixed(2)}, ${distribution.upper.toFixed(2)}, ${confidenceLevelToFormulaArgument(distribution.confidenceLevel).toFixed(2)})`; + const lower = formatDistributionFormulaNumber(distribution.lower); + const upper = formatDistributionFormulaNumber(distribution.upper); + const confidence = formatDistributionFormulaNumber( + confidenceLevelToFormulaArgument(distribution.confidenceLevel), + ); + return `LN.CI(${lower}, ${upper}, ${confidence})`; } - return `LN(${(distribution.mu ?? 0).toFixed(2)}, ${(distribution.sigma ?? 0).toFixed(2)})`; - case "uniform": - return `U(${(distribution.min ?? 0).toFixed(2)}, ${(distribution.max ?? 0).toFixed(2)})`; + const mu = formatDistributionFormulaNumber(distribution.mu ?? 0); + const sigma = formatDistributionFormulaNumber( + distribution.sigma ?? 0, + ); + return `LN(${mu}, ${sigma})`; + } + case "uniform": { + const min = formatDistributionFormulaNumber(distribution.min ?? 0); + const max = formatDistributionFormulaNumber(distribution.max ?? 0); + return `U(${min}, ${max})`; + } } } diff --git a/src/utils/distribution/distributionUtils.ts b/src/utils/distribution/distributionUtils.ts index 75e543c..1ecbfd2 100644 --- a/src/utils/distribution/distributionUtils.ts +++ b/src/utils/distribution/distributionUtils.ts @@ -70,6 +70,18 @@ export const confidenceLevelToFormulaArgument = ( confidenceLevel: number, ): number => confidenceLevel > 1 ? confidenceLevel / 100 : confidenceLevel +export const formatDistributionFormulaNumber = (value: number): string => { + if (!Number.isFinite(value)) { + return String(value) + } + + if (Number.isInteger(value) || Math.abs(value) >= 1) { + return value.toFixed(2) + } + + return Number(value.toPrecision(12)).toString() +} + type SampledDistributionLike = { samples?: unknown getSamples?: () => number[] diff --git a/tests/unit/components/spreadsheet/cellFormatting.test.ts b/tests/unit/components/spreadsheet/cellFormatting.test.ts index 51f2af6..013e2b2 100644 --- a/tests/unit/components/spreadsheet/cellFormatting.test.ts +++ b/tests/unit/components/spreadsheet/cellFormatting.test.ts @@ -86,6 +86,20 @@ describe('formatCellValue', () => { ).toBe('LN.CI(1.00, 2.00, 0.95)') }) + it('preserves small CI bounds in editable formulas', () => { + expect( + formatCellEditValue( + { + kind: 'normal', + source: 'ci', + lower: 0.009, + upper: 0.011, + confidenceLevel: 95, + }, + ), + ).toBe('N.CI(0.009, 0.011, 0.95)') + }) + it('formats sampled distributions', () => { expect( formatCellValue(sampledValue), diff --git a/tests/unit/examples/examples.test.ts b/tests/unit/examples/examples.test.ts index 04547f0..9b326ca 100644 --- a/tests/unit/examples/examples.test.ts +++ b/tests/unit/examples/examples.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { spreadsheetExamples } from "../../../src/examples/examples"; +import { formatFormulaBarCellValue } from "../../../src/hooks/useFormulaBar"; import { GaussFormulaEngine } from "../../../src/lib/formulaEngine"; import { parseSampledDistributionValue } from "../../../src/utils/distribution/distributionUtils"; @@ -37,29 +38,91 @@ describe("spreadsheet examples", () => { it.skipIf(!supportsExplicitDistributions)( "keeps the prompt-cost example mean aligned with the deterministic workbook total", () => { - const promptCostExample = spreadsheetExamples.find( - (example) => example.id === "prompt-cost-uncertainty", - ); - expect(promptCostExample).toBeDefined(); - - const engine = GaussFormulaEngine.buildEmpty({ - licenseKey: "gpl-v3", - language: "enGB", - useColumnIndex: true, - sampleSize: 10000, - }); - - engine.addSheet("Sheet1"); - engine.setSheetContent(0, promptCostExample!.data); - - try { - expect(engine.getCellValue(a1ToAddress("E6"))).toBe(10); - expect(engine.getCellValue(a1ToAddress("E7"))).toBe(2); - expect(meanOfSampledValue(engine.getCellValue(a1ToAddress("E15")))) - .toBeCloseTo(144, 0); - } finally { - engine.destroy(); - } + const promptCostExample = spreadsheetExamples.find( + (example) => example.id === "prompt-cost-uncertainty", + ); + expect(promptCostExample).toBeDefined(); + + const engine = GaussFormulaEngine.buildEmpty({ + licenseKey: "gpl-v3", + language: "enGB", + useColumnIndex: true, + sampleSize: 10000, + }); + + engine.addSheet("Sheet1"); + engine.setSheetContent(0, promptCostExample!.data); + + try { + expect(meanOfSampledValue(engine.getCellValue(a1ToAddress("E6")))) + .toBeCloseTo(10, 0); + expect(engine.getCellValue(a1ToAddress("E7"))).toBe(2); + expect(meanOfSampledValue(engine.getCellValue(a1ToAddress("E15")))) + .toBeCloseTo(146, 0); + } finally { + engine.destroy(); + } + }, + ); + + it.skipIf(!supportsExplicitDistributions)( + "keeps aggregate formulas sampled when inputs are uncertain", + () => { + const engine = GaussFormulaEngine.buildEmpty({ + licenseKey: "gpl-v3", + language: "enGB", + useColumnIndex: true, + sampleSize: 1000, + }); + + engine.addSheet("Sheet1"); + engine.setSheetContent(0, [ + ["N(10, 0)", "N(20, 0)", "=SUM(A1:B1)", "=AVERAGE(A1:B1)"], + ]); + + try { + expect(meanOfSampledValue(engine.getCellValue(a1ToAddress("C1")))) + .toBe(30); + expect(meanOfSampledValue(engine.getCellValue(a1ToAddress("D1")))) + .toBe(15); + } finally { + engine.destroy(); + } + }, + ); + + it.skipIf(!supportsExplicitDistributions)( + "keeps prompt-cost pricing inputs parseable after formula bar formatting", + () => { + const promptCostExample = spreadsheetExamples.find( + (example) => example.id === "prompt-cost-uncertainty", + ); + expect(promptCostExample).toBeDefined(); + + const engine = GaussFormulaEngine.buildEmpty({ + licenseKey: "gpl-v3", + language: "enGB", + useColumnIndex: true, + sampleSize: 1000, + }); + + engine.addSheet("Sheet1"); + engine.setSheetContent(0, promptCostExample!.data); + + try { + for (const cell of ["B11", "B12"]) { + const address = a1ToAddress(cell); + engine.setCellContents( + address, + formatFormulaBarCellValue(engine.getCellValue(address)), + ); + } + + expect(meanOfSampledValue(engine.getCellValue(a1ToAddress("E13")))) + .toBeGreaterThan(0); + } finally { + engine.destroy(); + } }, ); }); diff --git a/tests/unit/hooks/useFormulaBar.test.ts b/tests/unit/hooks/useFormulaBar.test.ts index d51bebf..62a92d3 100644 --- a/tests/unit/hooks/useFormulaBar.test.ts +++ b/tests/unit/hooks/useFormulaBar.test.ts @@ -31,6 +31,18 @@ describe("formatFormulaBarCellValue", () => { ).toBe("U(1.00, 2.00)"); }); + it("preserves small CI bounds in formulas", () => { + expect( + formatFormulaBarCellValue({ + kind: "normal", + source: "ci", + lower: 0.009, + upper: 0.011, + confidenceLevel: 95, + }), + ).toBe("N.CI(0.009, 0.011, 0.95)"); + }); + it("formats variance-backed summaries as standard deviation", () => { expect(formatFormulaBarCellValue({ mean: 5, variance: 4 })) .toBe("S(μ=5.00, σ=2.00)");