@reekon-tools/boldr-utils 1.10.0 → 1.10.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/calculator/evaluate.js +49 -7
- package/dist/calculator/expressionUnits.d.ts +95 -1
- package/dist/calculator/expressionUnits.js +288 -66
- package/dist/calculator/schema.d.ts +50 -9
- package/dist/calculator/schema.js +30 -6
- package/dist/calculator/units.d.ts +27 -0
- package/dist/calculator/units.js +47 -0
- package/dist/calculator/validate.d.ts +7 -0
- package/dist/calculator/validate.js +40 -5
- package/dist/utils/micrometersToUnit.js +60 -18
- package/package.json +1 -1
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { create, all } from 'mathjs';
|
|
2
2
|
import { ColumnType } from '../types/firestore.js';
|
|
3
|
-
import { equationForField, findField } from './schema.js';
|
|
3
|
+
import { bindingUnitForBase, equationForField, fieldDimension, findField, } from './schema.js';
|
|
4
4
|
import { conversionCellNumber, findConversionRow } from './conversionTable.js';
|
|
5
|
-
import {
|
|
5
|
+
import { deriveResultUnitForBase } from './expressionUnits.js';
|
|
6
|
+
import { fromCanonical, isEquationBase, toCanonical, } from './units.js';
|
|
6
7
|
// Trig in calculator equations works in DEGREES: angle fields are stored in
|
|
7
8
|
// degrees canonically, and construction authors write `H = W * tan(A)`
|
|
8
9
|
// expecting A in degrees. mathjs has no `angle` config option (evaluateFormula
|
|
@@ -107,6 +108,46 @@ export const numericFieldValue = (field, value, columnId) => {
|
|
|
107
108
|
return null;
|
|
108
109
|
}
|
|
109
110
|
};
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// Scope units.
|
|
113
|
+
//
|
|
114
|
+
// A base-anchored equation derives every unit from its base rather than
|
|
115
|
+
// reading `variableUnits` / `resultUnit` off the document. Those fields are
|
|
116
|
+
// still written (older app builds evaluate from them), but they are a cache of
|
|
117
|
+
// this derivation — and trusting a cache over its source is exactly how a
|
|
118
|
+
// stale entry silently rescales a live answer. So the evaluator recomputes.
|
|
119
|
+
//
|
|
120
|
+
// Equations with no base keep the legacy reading, byte for byte: saved
|
|
121
|
+
// calculator instances freeze their definition, so pre-base equations must go
|
|
122
|
+
// on evaluating exactly as they did the day they were saved.
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
/** The unit one variable's canonical value is re-expressed in before scoping. */
|
|
125
|
+
const scopeUnitFor = (definition, equation, variable, fieldId) => {
|
|
126
|
+
if (!isEquationBase(equation.base)) {
|
|
127
|
+
return equation.variableUnits?.[variable] ?? null;
|
|
128
|
+
}
|
|
129
|
+
const field = findField(definition, fieldId);
|
|
130
|
+
if (!field)
|
|
131
|
+
return null;
|
|
132
|
+
return bindingUnitForBase(equation.base, field, equation.variableColumnIds?.[variable]);
|
|
133
|
+
};
|
|
134
|
+
/** The unit the expression's raw result carries, or null when it needs none. */
|
|
135
|
+
const resultUnitFor = (definition, equation) => {
|
|
136
|
+
if (!isEquationBase(equation.base))
|
|
137
|
+
return equation.resultUnit ?? null;
|
|
138
|
+
const target = findField(definition, equation.targetFieldId);
|
|
139
|
+
// A number target takes the raw value — the whole point of evaluating in
|
|
140
|
+
// display units is that `area_in_ft² / 33` yields squares, not µm².
|
|
141
|
+
if (!target || fieldDimension(target) === 'none')
|
|
142
|
+
return null;
|
|
143
|
+
const derived = deriveResultUnitForBase(equation.base, definition.fields, equation);
|
|
144
|
+
if (derived.kind === 'unit')
|
|
145
|
+
return derived.unit;
|
|
146
|
+
if (derived.kind === 'none')
|
|
147
|
+
return null;
|
|
148
|
+
// Inference declined; the author's stored unit is the only information left.
|
|
149
|
+
return equation.resultUnit ?? null;
|
|
150
|
+
};
|
|
110
151
|
const evaluateEquationInContext = (equation, ctx) => {
|
|
111
152
|
const cached = ctx.cache.get(equation.id);
|
|
112
153
|
if (cached)
|
|
@@ -131,9 +172,9 @@ const evaluateEquationInContext = (equation, ctx) => {
|
|
|
131
172
|
}
|
|
132
173
|
return resolved;
|
|
133
174
|
}
|
|
134
|
-
// Field values resolve canonically;
|
|
135
|
-
//
|
|
136
|
-
const unit = equation
|
|
175
|
+
// Field values resolve canonically; the scope unit re-expresses each one
|
|
176
|
+
// in the equation's own terms before it enters the expression.
|
|
177
|
+
const unit = scopeUnitFor(ctx.definition, equation, variable, fieldId);
|
|
137
178
|
try {
|
|
138
179
|
scope[variable] =
|
|
139
180
|
unit != null ? fromCanonical(resolved.value, unit) : resolved.value;
|
|
@@ -163,11 +204,12 @@ const evaluateEquationInContext = (equation, ctx) => {
|
|
|
163
204
|
else {
|
|
164
205
|
// A result unit means the expression produced a display-unit value;
|
|
165
206
|
// convert back so the returned value is canonical like everything else.
|
|
207
|
+
const resultUnit = resultUnitFor(ctx.definition, equation);
|
|
166
208
|
try {
|
|
167
209
|
result = {
|
|
168
210
|
ok: true,
|
|
169
|
-
value:
|
|
170
|
-
? toCanonical(evaluated.value,
|
|
211
|
+
value: resultUnit != null
|
|
212
|
+
? toCanonical(evaluated.value, resultUnit)
|
|
171
213
|
: evaluated.value,
|
|
172
214
|
};
|
|
173
215
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type MathNode } from 'mathjs';
|
|
2
2
|
import type { CalculatorEquation, CalculatorField } from './schema.js';
|
|
3
|
-
import { type CalculatorUnit, type FieldDimension } from './units.js';
|
|
3
|
+
import { type CalculatorUnit, type EquationBase, type FieldDimension } from './units.js';
|
|
4
4
|
/** Constants mathjs resolves without a scope entry. */
|
|
5
5
|
export declare const MATHJS_CONSTANTS: Set<string>;
|
|
6
6
|
export type ParseResult = {
|
|
@@ -39,6 +39,20 @@ export interface InferredExpressionUnit {
|
|
|
39
39
|
unit: CalculatorUnit | null;
|
|
40
40
|
/** Distinct base units among the variables carrying that dimension. */
|
|
41
41
|
bases: string[];
|
|
42
|
+
/**
|
|
43
|
+
* Every distinct length-family base the variables contributed, whether or
|
|
44
|
+
* not the exponents survived to the result.
|
|
45
|
+
*
|
|
46
|
+
* Separate from `bases` because `bases` describes the RESULT, and a result
|
|
47
|
+
* can be dimensionless while its inputs still disagree: `(A_in * B_in) /
|
|
48
|
+
* C_ft²` cancels to a plain count, so `bases` is empty and `dimension` is
|
|
49
|
+
* 'none' — and the 144× scale error that ratio carries is invisible there.
|
|
50
|
+
* Tracked per axis, since a length and an angle in one expression is normal
|
|
51
|
+
* (trig consumes the angle) while two length bases never is.
|
|
52
|
+
*/
|
|
53
|
+
lengthBases: string[];
|
|
54
|
+
/** Every distinct angle base the variables contributed ('deg' / 'rad'). */
|
|
55
|
+
angleBases: string[];
|
|
42
56
|
}
|
|
43
57
|
export type ExpressionUnitInference = ({
|
|
44
58
|
ok: true;
|
|
@@ -71,6 +85,86 @@ export declare const unitScaleRatio: (declared: CalculatorUnit, actual: Calculat
|
|
|
71
85
|
* existing equivalent unit is preserved so `in_frac` isn't churned to `in`.
|
|
72
86
|
*/
|
|
73
87
|
export declare const deriveResultUnit: (fields: readonly CalculatorField[], equation: Pick<CalculatorEquation, "expression" | "variableToFieldId" | "variableColumnIds" | "variableUnits" | "resultUnit">) => CalculatorUnit | null;
|
|
88
|
+
/** The scope unit for every variable of a base-anchored equation. */
|
|
89
|
+
export declare const deriveVariableUnits: (base: EquationBase, fields: readonly CalculatorField[], equation: Pick<CalculatorEquation, "variableToFieldId" | "variableColumnIds">) => Record<string, CalculatorUnit>;
|
|
90
|
+
export type DerivedResultUnit =
|
|
91
|
+
/** The raw value carries this unit and must be converted back through it. */
|
|
92
|
+
{
|
|
93
|
+
kind: 'unit';
|
|
94
|
+
unit: CalculatorUnit;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Inference succeeded and the raw value needs NO conversion: dimensionless,
|
|
98
|
+
* or an angle (degrees are already canonical).
|
|
99
|
+
*/
|
|
100
|
+
| {
|
|
101
|
+
kind: 'none';
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Inference declined — an unmodelled function, a non-integer power, a
|
|
105
|
+
* dimension this vocabulary can't name. The base says nothing useful here,
|
|
106
|
+
* so callers fall back to whatever unit the equation already carried. This
|
|
107
|
+
* is the escape hatch the authoring UI's manual result-unit picker fills.
|
|
108
|
+
*/
|
|
109
|
+
| {
|
|
110
|
+
kind: 'unknown';
|
|
111
|
+
};
|
|
112
|
+
/** The unit a base-anchored expression's raw value carries. */
|
|
113
|
+
export declare const deriveResultUnitForBase: (base: EquationBase, fields: readonly CalculatorField[], equation: Pick<CalculatorEquation, "expression" | "variableToFieldId" | "variableColumnIds">) => DerivedResultUnit;
|
|
114
|
+
/** Where a migrated equation's base came from — reported by the migration. */
|
|
115
|
+
export type EquationBaseSource =
|
|
116
|
+
/** A bound conversion column's declared unit. */
|
|
117
|
+
'conversion-column'
|
|
118
|
+
/** An existing authored `variableUnits` entry. */
|
|
119
|
+
| 'variable-unit'
|
|
120
|
+
/** A bound measurement field's display unit. */
|
|
121
|
+
| 'field-default'
|
|
122
|
+
/** Nothing length-valued to anchor to; the base is inert. */
|
|
123
|
+
| 'fallback'
|
|
124
|
+
/** A variable is in a unit family no base can express — see `blockedBy`. */
|
|
125
|
+
| 'unanchorable';
|
|
126
|
+
export interface DerivedEquationBase {
|
|
127
|
+
/** Null when the equation cannot be anchored without changing its meaning. */
|
|
128
|
+
base: EquationBase | null;
|
|
129
|
+
source: EquationBaseSource;
|
|
130
|
+
/**
|
|
131
|
+
* Distinct bases seen at the winning tier. More than one means the source
|
|
132
|
+
* disagreed with itself and `base` is the most common of them — the case a
|
|
133
|
+
* migration should surface rather than apply silently.
|
|
134
|
+
*/
|
|
135
|
+
candidates: EquationBase[];
|
|
136
|
+
/**
|
|
137
|
+
* Units that blocked anchoring: cubic yards, liters, gallons — real units a
|
|
138
|
+
* field may hold, but outside the five families a base can name.
|
|
139
|
+
*/
|
|
140
|
+
blockedBy?: CalculatorUnit[];
|
|
141
|
+
}
|
|
142
|
+
/** Inert default when an equation has no length-valued binding at all. */
|
|
143
|
+
export declare const DEFAULT_EQUATION_BASE: EquationBase;
|
|
144
|
+
/**
|
|
145
|
+
* The base a pre-`base` equation should adopt, in descending order of what the
|
|
146
|
+
* source can be trusted to mean:
|
|
147
|
+
*
|
|
148
|
+
* 1. A bound conversion COLUMN's unit. A cell reading "5.33" is literal text
|
|
149
|
+
* a human typed; its unit is the only thing that says what the number is,
|
|
150
|
+
* so it is the one declaration in the equation that cannot be re-expressed
|
|
151
|
+
* without reinterpreting authored data.
|
|
152
|
+
* 2. An existing `variableUnits` entry — the author's own stated intent,
|
|
153
|
+
* even if it disagreed with its neighbours.
|
|
154
|
+
* 3. A bound measurement field's display unit. Values are stored canonically,
|
|
155
|
+
* so this only reflects a preference, but it is the author's preference.
|
|
156
|
+
* 4. Nothing length-valued in the equation, so the base cannot matter.
|
|
157
|
+
*
|
|
158
|
+
* Declines entirely (`base: null`) when a variable is ALREADY authored in a
|
|
159
|
+
* unit no base can name — cubic yards, liters, gallons. Re-expressing those is
|
|
160
|
+
* numerically lossless but semantically not: a gravel estimator multiplying a
|
|
161
|
+
* cu_yd volume by a tons-per-cubic-yard constant is correct only while the
|
|
162
|
+
* volume stays in cubic yards, and that constant is a bare number with nothing
|
|
163
|
+
* to declare its units. Silently rebasing it to cu_in would make the answer
|
|
164
|
+
* 46,656× too large. Better to leave such an equation on the pre-base path,
|
|
165
|
+
* where the mixed-base check still watches it, than to guess.
|
|
166
|
+
*/
|
|
167
|
+
export declare const deriveEquationBase: (fields: readonly CalculatorField[], equation: Pick<CalculatorEquation, "variableToFieldId" | "variableColumnIds" | "variableUnits">) => DerivedEquationBase;
|
|
74
168
|
/**
|
|
75
169
|
* Bring an equation's field-derived annotations — variable column bindings,
|
|
76
170
|
* variable units, result unit — back in line with the fields it references.
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { create, all } from 'mathjs';
|
|
2
|
-
import { ColumnType
|
|
3
|
-
import { bindingDefaultUnit, bindingDimension, fieldDimension, } from './schema.js';
|
|
2
|
+
import { ColumnType } from '../types/firestore.js';
|
|
3
|
+
import { bindingDefaultUnit, bindingDimension, bindingUnitForBase, equationBindings, fieldDimension, } from './schema.js';
|
|
4
4
|
import { findConversionColumn } from './conversionTable.js';
|
|
5
|
-
import {
|
|
5
|
+
import { UNIT_BY_BASE, equationBaseOfUnit, isEquationBase, toCanonical, unitBase, unitDimension, unitForBase, } from './units.js';
|
|
6
6
|
const math = create(all);
|
|
7
7
|
// ---------------------------------------------------------------------------
|
|
8
8
|
// Expression unit inference.
|
|
@@ -44,23 +44,41 @@ export const MATHJS_CONSTANTS = new Set([
|
|
|
44
44
|
'NaN',
|
|
45
45
|
'null',
|
|
46
46
|
]);
|
|
47
|
+
// Parse cache, mirroring evaluate.ts's compile cache and sized to match. Unit
|
|
48
|
+
// inference now runs on the evaluation path (an equation's scope units are
|
|
49
|
+
// derived from its base, not read off the document), so the same handful of
|
|
50
|
+
// expressions get parsed on every keystroke of the editor preview.
|
|
51
|
+
//
|
|
52
|
+
// The cached node is handed to callers directly. Every consumer in this
|
|
53
|
+
// package only reads it — `traverse` in expressionSymbols, the structural walk
|
|
54
|
+
// below — so nothing mutates the shared tree.
|
|
55
|
+
const parseCache = new Map();
|
|
56
|
+
const PARSE_CACHE_MAX = 200;
|
|
47
57
|
/** Shared mathjs parse — the one place expression text becomes a tree. */
|
|
48
58
|
export const parseExpression = (expression) => {
|
|
59
|
+
const hit = parseCache.get(expression);
|
|
60
|
+
if (hit)
|
|
61
|
+
return hit;
|
|
62
|
+
let result;
|
|
49
63
|
try {
|
|
50
|
-
|
|
64
|
+
result = { ok: true, node: math.parse(expression) };
|
|
51
65
|
}
|
|
52
66
|
catch (err) {
|
|
53
|
-
|
|
67
|
+
result = {
|
|
54
68
|
ok: false,
|
|
55
69
|
error: err instanceof Error ? err.message : String(err),
|
|
56
70
|
};
|
|
57
71
|
}
|
|
72
|
+
if (parseCache.size >= PARSE_CACHE_MAX)
|
|
73
|
+
parseCache.clear();
|
|
74
|
+
parseCache.set(expression, result);
|
|
75
|
+
return result;
|
|
58
76
|
};
|
|
59
77
|
// The math.js base token a unit reduces to: 'sq_ft' -> 'ft', 'in_frac' -> 'in',
|
|
60
78
|
// 'liter' -> 'L'. Two units share a base exactly when they are the same
|
|
61
79
|
// physical unit at different exponents, which is what makes a result unit
|
|
62
80
|
// nameable.
|
|
63
|
-
const baseUnitOf =
|
|
81
|
+
const baseUnitOf = unitBase;
|
|
64
82
|
/**
|
|
65
83
|
* Bases whose values are ALREADY canonical, so an equation in them needs no
|
|
66
84
|
* `resultUnit` at all (the pre-unit-annotation form: a canonical scope in
|
|
@@ -69,17 +87,6 @@ const baseUnitOf = (unit) => (CALCULATOR_UNIT_INFO[unit]?.mathUnit ?? '').split(
|
|
|
69
87
|
export const CANONICAL_BASES = new Set(['um', 'deg']);
|
|
70
88
|
const CANONICAL_LENGTH_BASE = 'um';
|
|
71
89
|
const CANONICAL_ANGLE_BASE = 'deg';
|
|
72
|
-
// Base + length exponent -> the unit that names it. Yards have no length
|
|
73
|
-
// entry (the Units enum has no yard) and liters/gallons no length base at all,
|
|
74
|
-
// so both simply fail to name a unit rather than guessing.
|
|
75
|
-
const UNIT_BY_BASE = {
|
|
76
|
-
mm: { 1: Units.Millimeters, 2: 'sq_mm', 3: 'cu_mm' },
|
|
77
|
-
cm: { 1: Units.Centimeters, 2: 'sq_cm', 3: 'cu_cm' },
|
|
78
|
-
m: { 1: Units.Meters, 2: 'sq_m', 3: 'cu_m' },
|
|
79
|
-
in: { 1: Units.Inches, 2: 'sq_in', 3: 'cu_in' },
|
|
80
|
-
ft: { 1: Units.Feet, 2: 'sq_ft', 3: 'cu_ft' },
|
|
81
|
-
yd: { 2: 'sq_yd', 3: 'cu_yd' },
|
|
82
|
-
};
|
|
83
90
|
const SCALAR = { len: 0, ang: 0 };
|
|
84
91
|
const isScalar = (e) => e.len === 0 && e.ang === 0;
|
|
85
92
|
const sameExponents = (a, b) => a.len === b.len && a.ang === b.ang;
|
|
@@ -301,28 +308,35 @@ export const inferExpressionUnit = (fields, equation) => {
|
|
|
301
308
|
if (!exponents) {
|
|
302
309
|
return { ok: false, reason: failure ?? 'unsupported-operation' };
|
|
303
310
|
}
|
|
311
|
+
// Every success path reports both axes; `bases` narrows to whichever axis
|
|
312
|
+
// the RESULT lives on.
|
|
313
|
+
const axes = { lengthBases: [...lengthBases], angleBases: [...angleBases] };
|
|
304
314
|
if (exponents.ang !== 0) {
|
|
305
315
|
// Angles don't combine with lengths into anything nameable (a length·deg
|
|
306
316
|
// has no unit here), and neither does deg².
|
|
307
317
|
if (exponents.len !== 0 || exponents.ang !== 1) {
|
|
308
318
|
return { ok: false, reason: 'unnameable-dimension' };
|
|
309
319
|
}
|
|
310
|
-
const bases =
|
|
320
|
+
const bases = axes.angleBases;
|
|
311
321
|
return {
|
|
312
322
|
ok: true,
|
|
313
323
|
dimension: 'angle',
|
|
314
324
|
unit: bases.length === 1 ? bases[0] : null,
|
|
315
325
|
bases,
|
|
326
|
+
...axes,
|
|
316
327
|
};
|
|
317
328
|
}
|
|
318
329
|
if (exponents.len === 0) {
|
|
319
|
-
|
|
330
|
+
// Dimensionless. `bases` is empty because the RESULT names no unit — but
|
|
331
|
+
// the per-axis lists still carry what went in, which is the only record
|
|
332
|
+
// that a cancelling ratio mixed inches with feet.
|
|
333
|
+
return { ok: true, dimension: 'none', unit: null, bases: [], ...axes };
|
|
320
334
|
}
|
|
321
335
|
if (exponents.len < 1 || exponents.len > 3) {
|
|
322
336
|
return { ok: false, reason: 'unnameable-dimension' };
|
|
323
337
|
}
|
|
324
338
|
const dimension = exponents.len === 1 ? 'length' : exponents.len === 2 ? 'area' : 'volume';
|
|
325
|
-
const bases =
|
|
339
|
+
const bases = axes.lengthBases;
|
|
326
340
|
return {
|
|
327
341
|
ok: true,
|
|
328
342
|
dimension,
|
|
@@ -330,6 +344,7 @@ export const inferExpressionUnit = (fields, equation) => {
|
|
|
330
344
|
? (UNIT_BY_BASE[bases[0]]?.[exponents.len] ?? null)
|
|
331
345
|
: null,
|
|
332
346
|
bases,
|
|
347
|
+
...axes,
|
|
333
348
|
};
|
|
334
349
|
};
|
|
335
350
|
/**
|
|
@@ -365,6 +380,151 @@ export const deriveResultUnit = (fields, equation) => {
|
|
|
365
380
|
? current
|
|
366
381
|
: inferred.unit;
|
|
367
382
|
};
|
|
383
|
+
// ---------------------------------------------------------------------------
|
|
384
|
+
// Base-anchored derivation.
|
|
385
|
+
//
|
|
386
|
+
// With `CalculatorEquation.base` set, units stop being authored and become a
|
|
387
|
+
// function of (base, binding dimension). `deriveVariableUnits` answers "what
|
|
388
|
+
// does each variable arrive as", `deriveResultUnitForBase` answers "what does
|
|
389
|
+
// the expression hand back", and `deriveEquationBase` picks a base for an
|
|
390
|
+
// equation that predates the field.
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
392
|
+
/** The scope unit for every variable of a base-anchored equation. */
|
|
393
|
+
export const deriveVariableUnits = (base, fields, equation) => {
|
|
394
|
+
const fieldById = new Map(fields.map((f) => [f.id, f]));
|
|
395
|
+
const units = {};
|
|
396
|
+
for (const { variable, fieldId, columnId } of equationBindings(equation)) {
|
|
397
|
+
const field = fieldById.get(fieldId);
|
|
398
|
+
if (!field)
|
|
399
|
+
continue;
|
|
400
|
+
const unit = bindingUnitForBase(base, field, columnId);
|
|
401
|
+
if (unit != null)
|
|
402
|
+
units[variable] = unit;
|
|
403
|
+
}
|
|
404
|
+
return units;
|
|
405
|
+
};
|
|
406
|
+
/** The unit a base-anchored expression's raw value carries. */
|
|
407
|
+
export const deriveResultUnitForBase = (base, fields, equation) => {
|
|
408
|
+
const inferred = inferExpressionUnit(fields, {
|
|
409
|
+
...equation,
|
|
410
|
+
variableUnits: deriveVariableUnits(base, fields, equation),
|
|
411
|
+
});
|
|
412
|
+
if (!inferred.ok)
|
|
413
|
+
return { kind: 'unknown' };
|
|
414
|
+
if (inferred.dimension === 'none' || inferred.dimension === 'angle') {
|
|
415
|
+
return { kind: 'none' };
|
|
416
|
+
}
|
|
417
|
+
const unit = unitForBase(base, inferred.dimension);
|
|
418
|
+
return unit ? { kind: 'unit', unit } : { kind: 'unknown' };
|
|
419
|
+
};
|
|
420
|
+
/** Inert default when an equation has no length-valued binding at all. */
|
|
421
|
+
export const DEFAULT_EQUATION_BASE = 'in';
|
|
422
|
+
const LENGTH_DIMENSIONS = new Set(['length', 'area', 'volume']);
|
|
423
|
+
// Most frequent base wins; ties break toward the first one seen, so the result
|
|
424
|
+
// is stable across runs (variable order is the document's own key order).
|
|
425
|
+
const mostCommon = (bases) => {
|
|
426
|
+
if (bases.length === 0)
|
|
427
|
+
return null;
|
|
428
|
+
const counts = new Map();
|
|
429
|
+
for (const b of bases)
|
|
430
|
+
counts.set(b, (counts.get(b) ?? 0) + 1);
|
|
431
|
+
let best = bases[0];
|
|
432
|
+
for (const b of bases) {
|
|
433
|
+
if ((counts.get(b) ?? 0) > (counts.get(best) ?? 0))
|
|
434
|
+
best = b;
|
|
435
|
+
}
|
|
436
|
+
return best;
|
|
437
|
+
};
|
|
438
|
+
/**
|
|
439
|
+
* The base a pre-`base` equation should adopt, in descending order of what the
|
|
440
|
+
* source can be trusted to mean:
|
|
441
|
+
*
|
|
442
|
+
* 1. A bound conversion COLUMN's unit. A cell reading "5.33" is literal text
|
|
443
|
+
* a human typed; its unit is the only thing that says what the number is,
|
|
444
|
+
* so it is the one declaration in the equation that cannot be re-expressed
|
|
445
|
+
* without reinterpreting authored data.
|
|
446
|
+
* 2. An existing `variableUnits` entry — the author's own stated intent,
|
|
447
|
+
* even if it disagreed with its neighbours.
|
|
448
|
+
* 3. A bound measurement field's display unit. Values are stored canonically,
|
|
449
|
+
* so this only reflects a preference, but it is the author's preference.
|
|
450
|
+
* 4. Nothing length-valued in the equation, so the base cannot matter.
|
|
451
|
+
*
|
|
452
|
+
* Declines entirely (`base: null`) when a variable is ALREADY authored in a
|
|
453
|
+
* unit no base can name — cubic yards, liters, gallons. Re-expressing those is
|
|
454
|
+
* numerically lossless but semantically not: a gravel estimator multiplying a
|
|
455
|
+
* cu_yd volume by a tons-per-cubic-yard constant is correct only while the
|
|
456
|
+
* volume stays in cubic yards, and that constant is a bare number with nothing
|
|
457
|
+
* to declare its units. Silently rebasing it to cu_in would make the answer
|
|
458
|
+
* 46,656× too large. Better to leave such an equation on the pre-base path,
|
|
459
|
+
* where the mixed-base check still watches it, than to guess.
|
|
460
|
+
*/
|
|
461
|
+
export const deriveEquationBase = (fields, equation) => {
|
|
462
|
+
const fieldById = new Map(fields.map((f) => [f.id, f]));
|
|
463
|
+
const fromColumns = [];
|
|
464
|
+
const fromVariableUnits = [];
|
|
465
|
+
const fromFieldDefaults = [];
|
|
466
|
+
const blockedBy = [];
|
|
467
|
+
for (const { variable, fieldId, columnId } of equationBindings(equation)) {
|
|
468
|
+
const field = fieldById.get(fieldId);
|
|
469
|
+
if (!field)
|
|
470
|
+
continue;
|
|
471
|
+
// Only length-family bindings can disagree about scale; angles are their
|
|
472
|
+
// own axis and dimensionless bindings carry nothing.
|
|
473
|
+
if (!LENGTH_DIMENSIONS.has(bindingDimension(field, columnId)))
|
|
474
|
+
continue;
|
|
475
|
+
// bindingDefaultUnit resolves an absent/unknown column id to the first
|
|
476
|
+
// column, matching what the evaluator actually reads.
|
|
477
|
+
const columnUnit = field.kind === ColumnType.ConversionTable
|
|
478
|
+
? bindingDefaultUnit(field, columnId)
|
|
479
|
+
: null;
|
|
480
|
+
const declared = equation.variableUnits?.[variable];
|
|
481
|
+
// The unit this variable is evaluating in TODAY. That is what a base has
|
|
482
|
+
// to be able to reproduce; a field's display default is only a preference.
|
|
483
|
+
const effective = declared ?? columnUnit;
|
|
484
|
+
if (effective != null && equationBaseOfUnit(effective) == null) {
|
|
485
|
+
blockedBy.push(effective);
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
if (columnUnit) {
|
|
489
|
+
const base = equationBaseOfUnit(columnUnit);
|
|
490
|
+
if (base)
|
|
491
|
+
fromColumns.push(base);
|
|
492
|
+
}
|
|
493
|
+
if (declared) {
|
|
494
|
+
const base = equationBaseOfUnit(declared);
|
|
495
|
+
if (base)
|
|
496
|
+
fromVariableUnits.push(base);
|
|
497
|
+
}
|
|
498
|
+
if (field.kind === ColumnType.Measurement) {
|
|
499
|
+
const base = equationBaseOfUnit(field.unit.defaultUnit);
|
|
500
|
+
if (base)
|
|
501
|
+
fromFieldDefaults.push(base);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
if (blockedBy.length > 0) {
|
|
505
|
+
return {
|
|
506
|
+
base: null,
|
|
507
|
+
source: 'unanchorable',
|
|
508
|
+
candidates: [],
|
|
509
|
+
blockedBy: [...new Set(blockedBy)],
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
const tiers = [
|
|
513
|
+
['conversion-column', fromColumns],
|
|
514
|
+
['variable-unit', fromVariableUnits],
|
|
515
|
+
['field-default', fromFieldDefaults],
|
|
516
|
+
];
|
|
517
|
+
for (const [source, candidates] of tiers) {
|
|
518
|
+
const base = mostCommon(candidates);
|
|
519
|
+
if (base)
|
|
520
|
+
return { base, source, candidates: [...new Set(candidates)] };
|
|
521
|
+
}
|
|
522
|
+
return {
|
|
523
|
+
base: DEFAULT_EQUATION_BASE,
|
|
524
|
+
source: 'fallback',
|
|
525
|
+
candidates: [],
|
|
526
|
+
};
|
|
527
|
+
};
|
|
368
528
|
// Rebuild an equation without a key, since Firestore rejects `undefined`.
|
|
369
529
|
const withoutResultUnit = (equation) => {
|
|
370
530
|
const { resultUnit: _dropped, ...rest } = equation;
|
|
@@ -375,6 +535,66 @@ const withoutVariableColumnIds = (equation) => {
|
|
|
375
535
|
const { variableColumnIds: _dropped, ...rest } = equation;
|
|
376
536
|
return rest;
|
|
377
537
|
};
|
|
538
|
+
// Rebuild an equation without a key, since Firestore rejects `undefined`.
|
|
539
|
+
const withoutVariableUnits = (equation) => {
|
|
540
|
+
const { variableUnits: _dropped, ...rest } = equation;
|
|
541
|
+
return rest;
|
|
542
|
+
};
|
|
543
|
+
/**
|
|
544
|
+
* The pre-base repair, kept verbatim for equations that cannot adopt a base.
|
|
545
|
+
* It only realigns a unit whose DIMENSION no longer matches its binding — it
|
|
546
|
+
* has no opinion about bases, which is precisely why it is safe here: an
|
|
547
|
+
* equation holding cubic yards keeps holding cubic yards.
|
|
548
|
+
*/
|
|
549
|
+
const reconcileLegacyUnits = (fields, fieldById, equation) => {
|
|
550
|
+
let repaired = equation;
|
|
551
|
+
if (repaired.variableUnits) {
|
|
552
|
+
const variableUnits = {};
|
|
553
|
+
let unitsChanged = false;
|
|
554
|
+
for (const [variable, unit] of Object.entries(repaired.variableUnits)) {
|
|
555
|
+
const field = fieldById.get(repaired.variableToFieldId[variable] ?? '');
|
|
556
|
+
if (!field) {
|
|
557
|
+
unitsChanged = true; // variable or field is gone
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
const columnId = repaired.variableColumnIds?.[variable];
|
|
561
|
+
if (unitDimension(unit) === bindingDimension(field, columnId)) {
|
|
562
|
+
variableUnits[variable] = unit;
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
const replacement = bindingDefaultUnit(field, columnId);
|
|
566
|
+
unitsChanged = true;
|
|
567
|
+
if (replacement)
|
|
568
|
+
variableUnits[variable] = replacement;
|
|
569
|
+
}
|
|
570
|
+
if (unitsChanged) {
|
|
571
|
+
repaired =
|
|
572
|
+
Object.keys(variableUnits).length > 0
|
|
573
|
+
? { ...repaired, variableUnits }
|
|
574
|
+
: withoutVariableUnits(repaired);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
const target = fieldById.get(repaired.targetFieldId);
|
|
578
|
+
const targetDimension = target ? fieldDimension(target) : 'none';
|
|
579
|
+
if (targetDimension === 'none') {
|
|
580
|
+
if (repaired.resultUnit != null)
|
|
581
|
+
repaired = withoutResultUnit(repaired);
|
|
582
|
+
return repaired;
|
|
583
|
+
}
|
|
584
|
+
const derived = deriveResultUnit(fields, repaired);
|
|
585
|
+
if (derived != null) {
|
|
586
|
+
if (repaired.resultUnit !== derived) {
|
|
587
|
+
repaired = { ...repaired, resultUnit: derived };
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
else if (repaired.resultUnit != null &&
|
|
591
|
+
unitDimension(repaired.resultUnit) !== targetDimension) {
|
|
592
|
+
// Not derivable AND not even the right dimension: drop it rather than
|
|
593
|
+
// keep converting a length factor into an area field.
|
|
594
|
+
repaired = withoutResultUnit(repaired);
|
|
595
|
+
}
|
|
596
|
+
return repaired;
|
|
597
|
+
};
|
|
378
598
|
/**
|
|
379
599
|
* Bring an equation's field-derived annotations — variable column bindings,
|
|
380
600
|
* variable units, result unit — back in line with the fields it references.
|
|
@@ -411,59 +631,61 @@ export const reconcileEquationUnits = (fields, equations) => {
|
|
|
411
631
|
: withoutVariableColumnIds(repaired);
|
|
412
632
|
}
|
|
413
633
|
}
|
|
414
|
-
// 2.
|
|
415
|
-
//
|
|
416
|
-
//
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
})();
|
|
444
|
-
}
|
|
634
|
+
// 2. Adopt a base. An equation authored before bases existed gets one
|
|
635
|
+
// inferred from what it already says (deriveEquationBase), so opening
|
|
636
|
+
// an old calculator and touching a field migrates it in place. An
|
|
637
|
+
// unrecognized stored base is treated as absent rather than trusted.
|
|
638
|
+
// Read against `repaired`: step 1 may have moved a variable back to the
|
|
639
|
+
// first column, whose unit is what the base should follow.
|
|
640
|
+
//
|
|
641
|
+
// An EXPLICIT base always wins — converting a cu_yd column into ft³ is
|
|
642
|
+
// well defined, and an author who asked for it meant it. Only the
|
|
643
|
+
// inference declines, and only for an equation already sitting in a
|
|
644
|
+
// unit family no base can name (see deriveEquationBase).
|
|
645
|
+
const base = isEquationBase(repaired.base)
|
|
646
|
+
? repaired.base
|
|
647
|
+
: deriveEquationBase(fields, repaired).base;
|
|
648
|
+
if (base == null) {
|
|
649
|
+
// Unanchorable: leave it exactly as the pre-base code would have.
|
|
650
|
+
repaired = reconcileLegacyUnits(fields, fieldById, repaired);
|
|
651
|
+
if (repaired !== equation)
|
|
652
|
+
changed = true;
|
|
653
|
+
return repaired;
|
|
654
|
+
}
|
|
655
|
+
if (repaired.base !== base)
|
|
656
|
+
repaired = { ...repaired, base };
|
|
657
|
+
// 3. Variable units are fully determined by the base and each binding's
|
|
658
|
+
// dimension, so they are not stored at all — the evaluator derives them.
|
|
659
|
+
// Anything left over from the pre-base form is dead weight that a reader
|
|
660
|
+
// would reasonably mistake for the source of truth.
|
|
661
|
+
if (repaired.variableUnits != null) {
|
|
662
|
+
repaired = withoutVariableUnits(repaired);
|
|
445
663
|
}
|
|
446
|
-
//
|
|
664
|
+
// 4. The result unit is derived the same way, with ONE exception worth
|
|
665
|
+
// persisting: an expression whose units inference cannot model at all
|
|
666
|
+
// (an unmodelled function, a dimension this vocabulary can't name). The
|
|
667
|
+
// base says nothing useful there, so the author picks, and that choice
|
|
668
|
+
// is real authored data rather than a cache.
|
|
669
|
+
//
|
|
670
|
+
// Number targets take the raw value either way — that is what makes
|
|
671
|
+
// `area_in_ft² / 33` read as 33 ft², not 33 µm².
|
|
447
672
|
const target = fieldById.get(equation.targetFieldId);
|
|
448
673
|
const targetDimension = target ? fieldDimension(target) : 'none';
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
if (
|
|
456
|
-
if (repaired.resultUnit !== derived) {
|
|
457
|
-
repaired = { ...repaired, resultUnit: derived };
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
else if (repaired.resultUnit != null &&
|
|
674
|
+
const derived = targetDimension === 'none'
|
|
675
|
+
? { kind: 'none' }
|
|
676
|
+
: deriveResultUnitForBase(base, fields, repaired);
|
|
677
|
+
if (derived.kind === 'unknown') {
|
|
678
|
+
// Keep the author's pick, unless it isn't even the target's dimension —
|
|
679
|
+
// that would convert a length factor into an area field.
|
|
680
|
+
if (repaired.resultUnit != null &&
|
|
461
681
|
unitDimension(repaired.resultUnit) !== targetDimension) {
|
|
462
|
-
// Not derivable AND not even the right dimension: drop it rather than
|
|
463
|
-
// keep converting a length factor into an area field.
|
|
464
682
|
repaired = withoutResultUnit(repaired);
|
|
465
683
|
}
|
|
466
684
|
}
|
|
685
|
+
else if (repaired.resultUnit != null) {
|
|
686
|
+
// Derivable, so a stored unit is either redundant or wrong. Drop it.
|
|
687
|
+
repaired = withoutResultUnit(repaired);
|
|
688
|
+
}
|
|
467
689
|
if (repaired !== equation)
|
|
468
690
|
changed = true;
|
|
469
691
|
return repaired;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ColumnType, type ColumnConfig, type ConversionTableColumnData, type InstructionsColumnData, type SelectColumnData, type DecimalTolerance, type FractionalTolerance } from '../types/firestore.js';
|
|
2
|
-
import type { AngleUnit, CalculatorUnit, FieldDimension, MeasurementDimension } from './units.js';
|
|
2
|
+
import type { AngleUnit, CalculatorUnit, EquationBase, FieldDimension, MeasurementDimension } from './units.js';
|
|
3
3
|
import type { CalculatorCategoryId } from './categories.js';
|
|
4
4
|
/**
|
|
5
5
|
* Version of the definition format itself. Bumped only on breaking schema
|
|
@@ -122,15 +122,45 @@ export interface CalculatorEquation {
|
|
|
122
122
|
*/
|
|
123
123
|
variableColumnIds?: Record<string, string>;
|
|
124
124
|
/**
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
125
|
+
* The ONE length base this expression evaluates in ('ft', 'in', 'mm', …) —
|
|
126
|
+
* and, when present, the only unit information the equation stores. Each
|
|
127
|
+
* variable's scope unit is this base at the variable's own exponent (ft,
|
|
128
|
+
* ft², ft³); the result unit is this base at the result's exponent. Both are
|
|
129
|
+
* computed on demand, never written down.
|
|
130
|
+
*
|
|
131
|
+
* This exists because per-variable units could disagree. Authoring
|
|
132
|
+
* `(A_in * B_in) / C_ft²` passed every dimensional check — length·length ÷
|
|
133
|
+
* area cancels to a valid dimensionless count — while computing 144× the
|
|
134
|
+
* truth, because nothing reconciled inches against feet. A single base makes
|
|
135
|
+
* that unrepresentable rather than merely detectable.
|
|
136
|
+
*
|
|
137
|
+
* The base is per EQUATION, not per calculator: values are stored
|
|
138
|
+
* canonically, so two equations may work in different bases and still feed
|
|
139
|
+
* each other — each converts in and out at its own edge.
|
|
140
|
+
*
|
|
141
|
+
* Absent means the pre-base form below. Still read, because saved instances
|
|
142
|
+
* freeze their definition and older docs migrate lazily, but never written
|
|
143
|
+
* for new equations.
|
|
144
|
+
*/
|
|
145
|
+
base?: EquationBase;
|
|
146
|
+
/**
|
|
147
|
+
* LEGACY (pre-`base`). Variable letter -> display unit its value enters the
|
|
148
|
+
* scope in; a mapped variable with no entry stays canonical (µm-scale).
|
|
149
|
+
*
|
|
150
|
+
* Ignored entirely when `base` is set, and stripped by
|
|
151
|
+
* reconcileEquationUnits, since the base determines every one of these — a
|
|
152
|
+
* stored copy could only go stale and be mistaken for the source of truth.
|
|
128
153
|
*/
|
|
129
154
|
variableUnits?: Record<string, CalculatorUnit>;
|
|
130
155
|
/**
|
|
131
156
|
* Display unit the expression's RESULT is expressed in; converted back to
|
|
132
157
|
* canonical before storage/chaining. Only meaningful for measurement/angle
|
|
133
158
|
* targets — number targets take the result raw. Absent = canonical.
|
|
159
|
+
*
|
|
160
|
+
* With `base` set this survives in ONE case: an expression whose units
|
|
161
|
+
* inference cannot model (an unmodelled function, an unnameable dimension).
|
|
162
|
+
* The base yields no answer there, so the author picks one and it is real
|
|
163
|
+
* authored data. Everywhere else it is derived and not stored.
|
|
134
164
|
*/
|
|
135
165
|
resultUnit?: CalculatorUnit;
|
|
136
166
|
}
|
|
@@ -212,13 +242,24 @@ export declare const bindingDimension: (field: CalculatorField, columnId?: strin
|
|
|
212
242
|
*/
|
|
213
243
|
export declare const fieldDefaultUnit: (field: CalculatorField) => CalculatorUnit | null;
|
|
214
244
|
/**
|
|
215
|
-
* The unit a BOUND value is
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
* catch. The authoring UI shows it read-only for that reason.
|
|
245
|
+
* The unit a BOUND value is DECLARED in — the conversion column's unit for a
|
|
246
|
+
* table binding (the unit the cell text was typed in), the field's display
|
|
247
|
+
* unit otherwise. This is what the value means at rest, not what it means
|
|
248
|
+
* inside an equation; `bindingUnitForBase` converts it to the latter.
|
|
220
249
|
*/
|
|
221
250
|
export declare const bindingDefaultUnit: (field: CalculatorField, columnId?: string) => CalculatorUnit | null;
|
|
251
|
+
/**
|
|
252
|
+
* The unit a bound value enters a base-anchored equation's scope in — the
|
|
253
|
+
* base's member at the binding's own exponent, so a length arrives in `ft` and
|
|
254
|
+
* an area off the same equation in `ft²`.
|
|
255
|
+
*
|
|
256
|
+
* Never an author choice, and deliberately independent of how the value is
|
|
257
|
+
* declared or displayed: a column typed in ft² feeding an inch-based equation
|
|
258
|
+
* converts to in², which is a lossless re-expression of the same quantity, not
|
|
259
|
+
* a reinterpretation of the author's cell text. Returns null for dimensionless
|
|
260
|
+
* bindings, which enter the scope raw.
|
|
261
|
+
*/
|
|
262
|
+
export declare const bindingUnitForBase: (base: EquationBase, field: CalculatorField, columnId?: string) => CalculatorUnit | null;
|
|
222
263
|
/** Fields whose value participates in equations as a number. */
|
|
223
264
|
export declare const isNumericFieldKind: (kind: CalculatorFieldKind) => kind is ColumnType.Number | ColumnType.Measurement | ColumnType.Angle | ColumnType.ConversionTable;
|
|
224
265
|
/** Field kinds an equation may target (compute into). */
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ColumnType, Units, } from '../types/firestore.js';
|
|
2
|
-
import { unitsForDimension } from './units.js';
|
|
2
|
+
import { unitForBase, unitsForDimension } from './units.js';
|
|
3
3
|
import { conversionColumnDimension, resolveConversionColumn, } from './conversionTable.js';
|
|
4
4
|
// ---------------------------------------------------------------------------
|
|
5
5
|
// The Construction Calculator definition — the shared artifact the web
|
|
@@ -101,15 +101,39 @@ export const fieldDefaultUnit = (field) => {
|
|
|
101
101
|
}
|
|
102
102
|
};
|
|
103
103
|
/**
|
|
104
|
-
* The unit a BOUND value is
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
* catch. The authoring UI shows it read-only for that reason.
|
|
104
|
+
* The unit a BOUND value is DECLARED in — the conversion column's unit for a
|
|
105
|
+
* table binding (the unit the cell text was typed in), the field's display
|
|
106
|
+
* unit otherwise. This is what the value means at rest, not what it means
|
|
107
|
+
* inside an equation; `bindingUnitForBase` converts it to the latter.
|
|
109
108
|
*/
|
|
110
109
|
export const bindingDefaultUnit = (field, columnId) => field.kind === ColumnType.ConversionTable
|
|
111
110
|
? (resolveConversionColumn(field.columnData, columnId).unit ?? null)
|
|
112
111
|
: fieldDefaultUnit(field);
|
|
112
|
+
/**
|
|
113
|
+
* The unit a bound value enters a base-anchored equation's scope in — the
|
|
114
|
+
* base's member at the binding's own exponent, so a length arrives in `ft` and
|
|
115
|
+
* an area off the same equation in `ft²`.
|
|
116
|
+
*
|
|
117
|
+
* Never an author choice, and deliberately independent of how the value is
|
|
118
|
+
* declared or displayed: a column typed in ft² feeding an inch-based equation
|
|
119
|
+
* converts to in², which is a lossless re-expression of the same quantity, not
|
|
120
|
+
* a reinterpretation of the author's cell text. Returns null for dimensionless
|
|
121
|
+
* bindings, which enter the scope raw.
|
|
122
|
+
*/
|
|
123
|
+
export const bindingUnitForBase = (base, field, columnId) => {
|
|
124
|
+
const dimension = bindingDimension(field, columnId);
|
|
125
|
+
switch (dimension) {
|
|
126
|
+
case 'none':
|
|
127
|
+
return null;
|
|
128
|
+
// Trig in equations runs in DEGREES (see evaluate.ts), so angles enter the
|
|
129
|
+
// scope as degrees whatever the base — and degrees are already canonical,
|
|
130
|
+
// making this conversion a no-op that exists for uniformity.
|
|
131
|
+
case 'angle':
|
|
132
|
+
return 'deg';
|
|
133
|
+
default:
|
|
134
|
+
return unitForBase(base, dimension);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
113
137
|
/** Fields whose value participates in equations as a number. */
|
|
114
138
|
export const isNumericFieldKind = (kind) => kind === ColumnType.Number ||
|
|
115
139
|
kind === ColumnType.Measurement ||
|
|
@@ -28,6 +28,33 @@ export declare const CALCULATOR_UNIT_INFO: Record<CalculatorUnit, CalculatorUnit
|
|
|
28
28
|
/** The math.js token for a dimension's canonical unit. '' for dimensionless. */
|
|
29
29
|
export declare const canonicalMathUnit: (dimension: FieldDimension) => string;
|
|
30
30
|
export declare const unitDimension: (unit: CalculatorUnit) => FieldDimension;
|
|
31
|
+
/** The math.js base token a unit reduces to: 'sq_ft' -> 'ft', 'in_frac' -> 'in'. */
|
|
32
|
+
export declare const unitBase: (unit: CalculatorUnit) => string;
|
|
33
|
+
/**
|
|
34
|
+
* Base + length exponent -> the unit that names it. Yards have no length entry
|
|
35
|
+
* (the `Units` enum has no yard) and liters/gallons no length base at all, so
|
|
36
|
+
* both simply fail to name a unit rather than guessing.
|
|
37
|
+
*/
|
|
38
|
+
export declare const UNIT_BY_BASE: Record<string, Partial<Record<number, CalculatorUnit>>>;
|
|
39
|
+
/**
|
|
40
|
+
* A base an equation may evaluate in. Only bases that name a unit at EVERY
|
|
41
|
+
* length exponent qualify — an equation in yards could not express a plain
|
|
42
|
+
* yard length, and liters/gallons aren't a length family at all. Both remain
|
|
43
|
+
* perfectly usable as field/column units; they just get converted into the
|
|
44
|
+
* equation's base like anything else.
|
|
45
|
+
*/
|
|
46
|
+
export type EquationBase = 'mm' | 'cm' | 'm' | 'in' | 'ft';
|
|
47
|
+
export declare const EQUATION_BASES: EquationBase[];
|
|
48
|
+
export declare const isEquationBase: (value: unknown) => value is EquationBase;
|
|
49
|
+
/** Length factors per dimension — an area is two lengths, a volume three. */
|
|
50
|
+
export declare const LENGTH_EXPONENT: Record<MeasurementDimension, 1 | 2 | 3>;
|
|
51
|
+
/** The unit naming `base` at a measurement dimension, e.g. ('ft','area') -> 'sq_ft'. */
|
|
52
|
+
export declare const unitForBase: (base: EquationBase, dimension: MeasurementDimension) => CalculatorUnit | null;
|
|
53
|
+
/**
|
|
54
|
+
* The equation base a unit belongs to, or null for units outside the five
|
|
55
|
+
* anchorable families (yd², liters, gallons, degrees, radians).
|
|
56
|
+
*/
|
|
57
|
+
export declare const equationBaseOfUnit: (unit: CalculatorUnit) => EquationBase | null;
|
|
31
58
|
export declare const unitsForDimension: (dimension: FieldDimension) => CalculatorUnit[];
|
|
32
59
|
/**
|
|
33
60
|
* A unit's token and its disambiguating qualifier as separate pieces. The
|
package/dist/calculator/units.js
CHANGED
|
@@ -68,6 +68,53 @@ export const canonicalMathUnit = (dimension) => {
|
|
|
68
68
|
}
|
|
69
69
|
};
|
|
70
70
|
export const unitDimension = (unit) => CALCULATOR_UNIT_INFO[unit]?.dimension ?? 'none';
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Equation bases
|
|
73
|
+
//
|
|
74
|
+
// An equation evaluates in ONE length base (see CalculatorEquation.base). The
|
|
75
|
+
// base names a family — `ft` covers ft, ft², ft³ — and each variable's scope
|
|
76
|
+
// unit is that family's member at the variable's own exponent. This is what
|
|
77
|
+
// makes `(A * B) / C` meaningful: A and B arrive in ft, C in ft², and the
|
|
78
|
+
// ratio is a true dimensionless count rather than a number that happens to
|
|
79
|
+
// divide.
|
|
80
|
+
//
|
|
81
|
+
// Angles are a SEPARATE axis and never participate: the evaluator's trig runs
|
|
82
|
+
// in degrees, so angle values always enter the scope as degrees regardless of
|
|
83
|
+
// the base.
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
/** The math.js base token a unit reduces to: 'sq_ft' -> 'ft', 'in_frac' -> 'in'. */
|
|
86
|
+
export const unitBase = (unit) => (CALCULATOR_UNIT_INFO[unit]?.mathUnit ?? '').split('^')[0];
|
|
87
|
+
/**
|
|
88
|
+
* Base + length exponent -> the unit that names it. Yards have no length entry
|
|
89
|
+
* (the `Units` enum has no yard) and liters/gallons no length base at all, so
|
|
90
|
+
* both simply fail to name a unit rather than guessing.
|
|
91
|
+
*/
|
|
92
|
+
export const UNIT_BY_BASE = {
|
|
93
|
+
mm: { 1: Units.Millimeters, 2: 'sq_mm', 3: 'cu_mm' },
|
|
94
|
+
cm: { 1: Units.Centimeters, 2: 'sq_cm', 3: 'cu_cm' },
|
|
95
|
+
m: { 1: Units.Meters, 2: 'sq_m', 3: 'cu_m' },
|
|
96
|
+
in: { 1: Units.Inches, 2: 'sq_in', 3: 'cu_in' },
|
|
97
|
+
ft: { 1: Units.Feet, 2: 'sq_ft', 3: 'cu_ft' },
|
|
98
|
+
yd: { 2: 'sq_yd', 3: 'cu_yd' },
|
|
99
|
+
};
|
|
100
|
+
export const EQUATION_BASES = ['mm', 'cm', 'm', 'in', 'ft'];
|
|
101
|
+
export const isEquationBase = (value) => typeof value === 'string' && EQUATION_BASES.includes(value);
|
|
102
|
+
/** Length factors per dimension — an area is two lengths, a volume three. */
|
|
103
|
+
export const LENGTH_EXPONENT = {
|
|
104
|
+
length: 1,
|
|
105
|
+
area: 2,
|
|
106
|
+
volume: 3,
|
|
107
|
+
};
|
|
108
|
+
/** The unit naming `base` at a measurement dimension, e.g. ('ft','area') -> 'sq_ft'. */
|
|
109
|
+
export const unitForBase = (base, dimension) => UNIT_BY_BASE[base]?.[LENGTH_EXPONENT[dimension]] ?? null;
|
|
110
|
+
/**
|
|
111
|
+
* The equation base a unit belongs to, or null for units outside the five
|
|
112
|
+
* anchorable families (yd², liters, gallons, degrees, radians).
|
|
113
|
+
*/
|
|
114
|
+
export const equationBaseOfUnit = (unit) => {
|
|
115
|
+
const base = unitBase(unit);
|
|
116
|
+
return isEquationBase(base) ? base : null;
|
|
117
|
+
};
|
|
71
118
|
export const unitsForDimension = (dimension) => Object.keys(CALCULATOR_UNIT_INFO).filter((u) => CALCULATOR_UNIT_INFO[u].dimension === dimension);
|
|
72
119
|
/**
|
|
73
120
|
* A unit's token and its disambiguating qualifier as separate pieces. The
|
|
@@ -164,6 +164,13 @@ export declare const calculatorDefinitionSchema: z.ZodObject<{
|
|
|
164
164
|
expression: z.ZodString;
|
|
165
165
|
variableToFieldId: z.ZodRecord<z.ZodString, z.ZodString>;
|
|
166
166
|
variableColumnIds: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
167
|
+
base: z.ZodOptional<z.ZodEnum<{
|
|
168
|
+
in: "in";
|
|
169
|
+
ft: "ft";
|
|
170
|
+
m: "m";
|
|
171
|
+
mm: "mm";
|
|
172
|
+
cm: "cm";
|
|
173
|
+
}>>;
|
|
167
174
|
variableUnits: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
168
175
|
resultUnit: z.ZodOptional<z.ZodString>;
|
|
169
176
|
}, z.core.$strip>>;
|
|
@@ -2,8 +2,8 @@ import { z } from 'zod';
|
|
|
2
2
|
import { ColumnType, DecimalTolerance, FractionalTolerance, } from '../types/firestore.js';
|
|
3
3
|
import { CALCULATOR_SCHEMA_VERSION, bindingDimension, equationForField, fieldDimension, isEquationTargetKind, isNumericFieldKind, } from './schema.js';
|
|
4
4
|
import { conversionBindingLabel, conversionCellValue, conversionColumnLabel, conversionColumns, findConversionColumn, hasMultipleConversionColumns, } from './conversionTable.js';
|
|
5
|
-
import { CALCULATOR_UNIT_INFO, unitDimension, } from './units.js';
|
|
6
|
-
import { MATHJS_CONSTANTS, inferExpressionUnit, isEquivalentUnit, parseExpression, producesCanonicalValue, unitScaleRatio, } from './expressionUnits.js';
|
|
5
|
+
import { CALCULATOR_UNIT_INFO, EQUATION_BASES, isEquationBase, unitDimension, } from './units.js';
|
|
6
|
+
import { MATHJS_CONSTANTS, deriveVariableUnits, inferExpressionUnit, isEquivalentUnit, parseExpression, producesCanonicalValue, unitScaleRatio, } from './expressionUnits.js';
|
|
7
7
|
import { isCalculatorCategoryId } from './categories.js';
|
|
8
8
|
// ---------------------------------------------------------------------------
|
|
9
9
|
// Definition validation: zod for structure, plus the cross-field invariants
|
|
@@ -106,6 +106,7 @@ const calculatorEquationSchema = z.object({
|
|
|
106
106
|
expression: z.string().min(1),
|
|
107
107
|
variableToFieldId: z.record(z.string(), z.string()),
|
|
108
108
|
variableColumnIds: z.record(z.string(), z.string()).optional(),
|
|
109
|
+
base: z.enum(EQUATION_BASES).optional(),
|
|
109
110
|
variableUnits: z.record(z.string(), calculatorUnitSchema).optional(),
|
|
110
111
|
resultUnit: calculatorUnitSchema.optional(),
|
|
111
112
|
});
|
|
@@ -402,7 +403,14 @@ export const validateCalculatorDefinition = (input) => {
|
|
|
402
403
|
// The unit the EXPRESSION produces, which is what `resultUnit` claims to
|
|
403
404
|
// be. Hoisted out of the `target` branch because the number-target check
|
|
404
405
|
// further down needs it too.
|
|
405
|
-
|
|
406
|
+
//
|
|
407
|
+
// Inferred from what the equation will ACTUALLY evaluate with: for a
|
|
408
|
+
// base-anchored equation that is the derived units, not whatever the
|
|
409
|
+
// document stores. Otherwise a stale cache would make every check below
|
|
410
|
+
// reason about units the evaluator is going to ignore.
|
|
411
|
+
const inferred = inferExpressionUnit(def.fields, isEquationBase(eq.base)
|
|
412
|
+
? { ...eq, variableUnits: deriveVariableUnits(eq.base, def.fields, eq) }
|
|
413
|
+
: eq);
|
|
406
414
|
if (target) {
|
|
407
415
|
const dim = fieldDimension(target);
|
|
408
416
|
if (eq.resultUnit != null) {
|
|
@@ -427,6 +435,11 @@ export const validateCalculatorDefinition = (input) => {
|
|
|
427
435
|
if (inferred.dimension !== dim) {
|
|
428
436
|
push('expression-dimension-mismatch', `${path}.expression`, `Expression produces ${describeDimension(inferred.dimension)} but "${target.name}" is ${describeDimension(dim)} — check the variable units and the expression`);
|
|
429
437
|
}
|
|
438
|
+
else if (isEquationBase(eq.base)) {
|
|
439
|
+
// Derived from the base at evaluation time, so it can be neither
|
|
440
|
+
// missing nor mis-scaled — the two checks below exist only for the
|
|
441
|
+
// pre-base form, where the result unit was an unverified assertion.
|
|
442
|
+
}
|
|
430
443
|
else if (eq.resultUnit == null) {
|
|
431
444
|
if (!producesCanonicalValue(inferred)) {
|
|
432
445
|
push('missing-result-unit', `${path}.resultUnit`, `Expression produces a value in ${inferred.unit ? `"${inferred.unit}"` : inferred.bases.join('/')} but no result unit is set, so it will be stored as a canonical value. Set the result unit${inferred.unit ? ` to "${inferred.unit}"` : ''}.`);
|
|
@@ -444,8 +457,24 @@ export const validateCalculatorDefinition = (input) => {
|
|
|
444
457
|
push('result-unit-scale-mismatch', `${path}.resultUnit`, `Expression produces "${inferred.unit}" but the result unit is "${eq.resultUnit}" — computed values will be ${off}. Use "${inferred.unit}", or change the variable units to match.`);
|
|
445
458
|
}
|
|
446
459
|
}
|
|
447
|
-
|
|
448
|
-
|
|
460
|
+
// Base-anchored equations cannot mix bases — every unit comes from the
|
|
461
|
+
// one base — so this only ever fires on the pre-base authored form.
|
|
462
|
+
//
|
|
463
|
+
// Checked PER AXIS. A length and an angle in one expression is ordinary
|
|
464
|
+
// (trig consumes the angle and hands back a ratio); two length bases
|
|
465
|
+
// never is. And read off `lengthBases`/`angleBases` rather than `bases`,
|
|
466
|
+
// because `bases` describes the result: `(A_in * B_in) / C_ft²` cancels
|
|
467
|
+
// to a dimensionless count, leaving `bases` empty and the 144× scale
|
|
468
|
+
// error it carries completely invisible here.
|
|
469
|
+
if (inferred.ok && !isEquationBase(eq.base)) {
|
|
470
|
+
for (const [axis, axisBases] of [
|
|
471
|
+
['length', inferred.lengthBases],
|
|
472
|
+
['angle', inferred.angleBases],
|
|
473
|
+
]) {
|
|
474
|
+
if (axisBases.length < 2)
|
|
475
|
+
continue;
|
|
476
|
+
push('mixed-base-units', `${path}.variableUnits`, `Variables mix ${axisBases.join(' and ')} in one expression, so ${axis === 'length' ? 'lengths' : 'angles'} of different scales are being combined as if they were the same — the result is silently off by a fixed factor. Put them all in one unit, or set the equation's base.`);
|
|
477
|
+
}
|
|
449
478
|
}
|
|
450
479
|
if (!inferred.ok && inferred.reason === 'inconsistent-terms') {
|
|
451
480
|
push('expression-mixed-dimensions', `${path}.expression`, 'Expression adds or compares terms of different dimensions (a length and an area, say) — one of them is probably the wrong field', 'warning');
|
|
@@ -468,7 +497,13 @@ export const validateCalculatorDefinition = (input) => {
|
|
|
468
497
|
// authoring where a µm-scale value in a unitless field is intended, and
|
|
469
498
|
// nothing gates on `ok`: mobile only console.warns in __DEV__, and the
|
|
470
499
|
// web editor shows the banner without blocking save.
|
|
500
|
+
//
|
|
501
|
+
// Cannot happen once an equation has a base: every dimensioned binding
|
|
502
|
+
// gets a unit derived from it, so there is no way for a canonical value to
|
|
503
|
+
// reach the scope. Skipped rather than left to read the absent
|
|
504
|
+
// `variableUnits` and report every variable as unitless.
|
|
471
505
|
if (target?.kind === ColumnType.Number &&
|
|
506
|
+
!isEquationBase(eq.base) &&
|
|
472
507
|
(!inferred.ok || inferred.dimension !== 'none')) {
|
|
473
508
|
for (const [variable, fieldId] of Object.entries(eq.variableToFieldId)) {
|
|
474
509
|
const mapped = fieldById.get(fieldId);
|
|
@@ -24,6 +24,27 @@ export const roundToT1Values = (value, u) => {
|
|
|
24
24
|
return Math.round(value / 500) * 500;
|
|
25
25
|
}
|
|
26
26
|
};
|
|
27
|
+
/**
|
|
28
|
+
* Lowest terms. A fraction is read off a tape, so the denominator has to be
|
|
29
|
+
* the smallest one that names the value: 2/16 is written 1/8, never "6 2/16".
|
|
30
|
+
* The tolerance sets the PRECISION the value is rounded to, not the
|
|
31
|
+
* denominator it prints with.
|
|
32
|
+
*/
|
|
33
|
+
const reduceFraction = (numerator, denominator) => {
|
|
34
|
+
let a = numerator;
|
|
35
|
+
let b = denominator;
|
|
36
|
+
while (b !== 0) {
|
|
37
|
+
[a, b] = [b, a % b];
|
|
38
|
+
}
|
|
39
|
+
const divisor = a || 1;
|
|
40
|
+
return [numerator / divisor, denominator / divisor];
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Re-apply the sign to a composite string built from a magnitude. Guards the
|
|
44
|
+
* `-0` case: a value that rounds to nothing at the current tolerance reads as
|
|
45
|
+
* plain zero, not as a negative.
|
|
46
|
+
*/
|
|
47
|
+
const signPrefix = (signed, body) => signed < 0 && /[1-9]/.test(body) ? `-${body}` : body;
|
|
27
48
|
export const convertMicrometers = (micrometers, unit, fractionalTolerance, decimalTolerance) => {
|
|
28
49
|
if (micrometers == null || isNaN(micrometers)) {
|
|
29
50
|
return { value: 'NaN', unit: '' };
|
|
@@ -39,18 +60,22 @@ export const convertMicrometers = (micrometers, unit, fractionalTolerance, decim
|
|
|
39
60
|
displayUnit = 'in';
|
|
40
61
|
if (unit === Units.FractionalInches) {
|
|
41
62
|
const denominator = parseInt(fractionalTolerance, 10);
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const
|
|
63
|
+
// Split the MAGNITUDE, then re-apply the sign: Math.floor walks away
|
|
64
|
+
// from zero on a negative, so -6 1/8 came out as `-7 7/8`.
|
|
65
|
+
const magnitude = Math.abs(inches);
|
|
66
|
+
const whole = Math.floor(magnitude);
|
|
67
|
+
const numerator = Math.round((magnitude - whole) * denominator);
|
|
45
68
|
if (numerator === denominator) {
|
|
46
69
|
value = `${whole + 1}`;
|
|
47
70
|
}
|
|
71
|
+
else if (numerator === 0) {
|
|
72
|
+
value = `${whole}`;
|
|
73
|
+
}
|
|
48
74
|
else {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
? `${whole}`
|
|
52
|
-
: `${whole} ${numerator}/${denominator}`;
|
|
75
|
+
const [num, den] = reduceFraction(numerator, denominator);
|
|
76
|
+
value = `${whole} ${num}/${den}`;
|
|
53
77
|
}
|
|
78
|
+
value = signPrefix(inches, value);
|
|
54
79
|
}
|
|
55
80
|
else {
|
|
56
81
|
value = inches.toFixed(decimalTolerance.length - 2);
|
|
@@ -61,28 +86,45 @@ export const convertMicrometers = (micrometers, unit, fractionalTolerance, decim
|
|
|
61
86
|
case Units.FeetInchesFractional:
|
|
62
87
|
case Units.FeetInchesDecimal: {
|
|
63
88
|
const feet = converted.toNumber('ft');
|
|
64
|
-
|
|
65
|
-
|
|
89
|
+
// Both COMPOSITE feet-inches units split the magnitude and re-apply the
|
|
90
|
+
// sign (see the fractional-inch branch) — otherwise -5' 6" printed as
|
|
91
|
+
// `-6' 6"`. Plain decimal feet keeps the signed value: toFixed renders
|
|
92
|
+
// that correctly on its own.
|
|
93
|
+
const composite = unit === Units.FeetInchesFractional ||
|
|
94
|
+
unit === Units.FeetInchesDecimal;
|
|
95
|
+
const magnitudeFeet = composite ? Math.abs(feet) : feet;
|
|
96
|
+
const wholeFeet = Math.floor(magnitudeFeet);
|
|
97
|
+
const fractionalFeet = magnitudeFeet - wholeFeet;
|
|
66
98
|
displayUnit = 'ft';
|
|
67
99
|
if (unit === Units.FeetInchesFractional) {
|
|
68
100
|
const inches = fractionalFeet * 12;
|
|
69
|
-
const wholeInches = Math.floor(inches);
|
|
70
|
-
const fractional = inches - wholeInches;
|
|
71
101
|
const denominator = parseInt(fractionalTolerance, 10);
|
|
72
|
-
|
|
102
|
+
let wholeInches = Math.floor(inches);
|
|
103
|
+
let numerator = Math.round((inches - wholeInches) * denominator);
|
|
104
|
+
// Carry, twice: a fraction that rounds up to a whole inch becomes an
|
|
105
|
+
// inch, and a twelfth inch becomes a foot. Without the second carry
|
|
106
|
+
// 5.9999 ft printed as `5' 12"`.
|
|
73
107
|
if (numerator === denominator) {
|
|
74
|
-
|
|
108
|
+
numerator = 0;
|
|
109
|
+
wholeInches += 1;
|
|
110
|
+
}
|
|
111
|
+
let displayFeet = wholeFeet;
|
|
112
|
+
if (wholeInches === 12) {
|
|
113
|
+
wholeInches = 0;
|
|
114
|
+
displayFeet += 1;
|
|
115
|
+
}
|
|
116
|
+
if (numerator === 0) {
|
|
117
|
+
value = `${displayFeet}' ${wholeInches}"`;
|
|
75
118
|
}
|
|
76
119
|
else {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
? `${wholeFeet}' ${wholeInches}"`
|
|
80
|
-
: `${wholeFeet}' ${wholeInches} ${numerator}/${denominator}"`;
|
|
120
|
+
const [num, den] = reduceFraction(numerator, denominator);
|
|
121
|
+
value = `${displayFeet}' ${wholeInches} ${num}/${den}"`;
|
|
81
122
|
}
|
|
123
|
+
value = signPrefix(feet, value);
|
|
82
124
|
}
|
|
83
125
|
else if (unit === Units.FeetInchesDecimal) {
|
|
84
126
|
const inches = (fractionalFeet * 12).toFixed(decimalTolerance.length - 2);
|
|
85
|
-
value = `${wholeFeet}' ${inches}"
|
|
127
|
+
value = signPrefix(feet, `${wholeFeet}' ${inches}"`);
|
|
86
128
|
}
|
|
87
129
|
else {
|
|
88
130
|
value = feet.toFixed(decimalTolerance.length - 2);
|