@reekon-tools/boldr-utils 1.8.4 → 1.9.0
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/annotation/canvas/AnnotationCanvasInner.native.js +41 -10
- package/dist/annotation/canvas/measurementGeometry.js +8 -8
- package/dist/annotation/canvas/stampLayout.js +59 -54
- package/dist/annotation/canvas/useAnnotationCanvasState.d.ts +4 -1
- package/dist/annotation/canvas/useAnnotationCanvasState.js +88 -38
- package/dist/calculator/evaluate.js +38 -4
- package/dist/calculator/expressionUnits.d.ts +84 -0
- package/dist/calculator/expressionUnits.js +439 -0
- package/dist/calculator/index.d.ts +1 -0
- package/dist/calculator/index.js +1 -0
- package/dist/calculator/schema.d.ts +18 -0
- package/dist/calculator/schema.js +26 -5
- package/dist/calculator/validate.d.ts +12 -2
- package/dist/calculator/validate.js +116 -43
- package/dist/theme/index.d.ts +3 -0
- package/dist/theme/index.js +3 -0
- package/dist/theme/preset.d.ts +120 -0
- package/dist/theme/preset.js +25 -0
- package/dist/theme/semantic.d.ts +136 -0
- package/dist/theme/semantic.js +116 -0
- package/package.json +1 -1
- package/dist/canvas/AnnotationCanvas.d.ts +0 -11
- package/dist/canvas/AnnotationCanvas.js +0 -10
- package/dist/canvas/AnnotationCanvas.native.d.ts +0 -8
- package/dist/canvas/AnnotationCanvas.native.js +0 -6
- package/dist/canvas/AnnotationCanvasInner.d.ts +0 -39
- package/dist/canvas/AnnotationCanvasInner.js +0 -219
- package/dist/canvas/AnnotationCanvasInner.native.d.ts +0 -35
- package/dist/canvas/AnnotationCanvasInner.native.js +0 -138
- package/dist/canvas/AnnotationCanvasSkia.d.ts +0 -27
- package/dist/canvas/AnnotationCanvasSkia.js +0 -20
- package/dist/canvas/Tool.d.ts +0 -38
- package/dist/canvas/Tool.js +0 -1
- package/dist/canvas/elements/BackgroundImageElement.d.ts +0 -9
- package/dist/canvas/elements/BackgroundImageElement.js +0 -37
- package/dist/canvas/elements/MeasurementStampElement.d.ts +0 -13
- package/dist/canvas/elements/MeasurementStampElement.js +0 -30
- package/dist/canvas/elements/ShapeElement.d.ts +0 -7
- package/dist/canvas/elements/ShapeElement.js +0 -62
- package/dist/canvas/elements/StrokeElement.d.ts +0 -7
- package/dist/canvas/elements/StrokeElement.js +0 -18
- package/dist/canvas/measurementPicker.d.ts +0 -10
- package/dist/canvas/measurementPicker.js +0 -1
- package/dist/canvas/measurementStampOverlay.d.ts +0 -11
- package/dist/canvas/measurementStampOverlay.js +0 -1
- package/dist/canvas/pointerAdapter.d.ts +0 -3
- package/dist/canvas/pointerAdapter.js +0 -19
- package/dist/canvas/stampLayout.d.ts +0 -5
- package/dist/canvas/stampLayout.js +0 -14
- package/dist/canvas/tools/measurementStampTool.d.ts +0 -9
- package/dist/canvas/tools/measurementStampTool.js +0 -37
- package/dist/canvas/tools/panTool.d.ts +0 -5
- package/dist/canvas/tools/panTool.js +0 -25
- package/dist/canvas/tools/penTool.d.ts +0 -13
- package/dist/canvas/tools/penTool.js +0 -68
- package/dist/canvas/tools/selectTool.d.ts +0 -2
- package/dist/canvas/tools/selectTool.js +0 -182
- package/dist/canvas/useAnnotationCanvasState.d.ts +0 -54
- package/dist/canvas/useAnnotationCanvasState.js +0 -210
- package/dist/canvas/viewport.d.ts +0 -16
- package/dist/canvas/viewport.js +0 -54
- package/dist/data/AnnotationDataContext.d.ts +0 -8
- package/dist/data/AnnotationDataContext.js +0 -11
- package/dist/data/AnnotationDataProvider.d.ts +0 -65
- package/dist/data/AnnotationDataProvider.js +0 -4
- package/dist/data/InMemoryAnnotationProvider.d.ts +0 -30
- package/dist/data/InMemoryAnnotationProvider.js +0 -197
- package/dist/data/canvasPersistence.d.ts +0 -3
- package/dist/data/canvasPersistence.js +0 -26
- package/dist/data/hooks/useAnnotationCanvasDoc.d.ts +0 -33
- package/dist/data/hooks/useAnnotationCanvasDoc.js +0 -314
- package/dist/data/hooks/useAnnotationDoc.d.ts +0 -7
- package/dist/data/hooks/useAnnotationDoc.js +0 -33
- package/dist/data/hooks/useAnnotationList.d.ts +0 -7
- package/dist/data/hooks/useAnnotationList.js +0 -26
- package/dist/data/hooks/useAnnotationMutations.d.ts +0 -9
- package/dist/data/hooks/useAnnotationMutations.js +0 -11
- package/dist/hooks/useParseMeasurement.d.ts +0 -4
- package/dist/hooks/useParseMeasurement.js +0 -14
- package/dist/utils/evaluateFormula.d.ts +0 -20
- package/dist/utils/evaluateFormula.js +0 -31
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { type MathNode } from 'mathjs';
|
|
2
|
+
import type { CalculatorEquation, CalculatorField } from './schema.js';
|
|
3
|
+
import { type CalculatorUnit, type FieldDimension } from './units.js';
|
|
4
|
+
/** Constants mathjs resolves without a scope entry. */
|
|
5
|
+
export declare const MATHJS_CONSTANTS: Set<string>;
|
|
6
|
+
export type ParseResult = {
|
|
7
|
+
ok: true;
|
|
8
|
+
node: MathNode;
|
|
9
|
+
} | {
|
|
10
|
+
ok: false;
|
|
11
|
+
error: string;
|
|
12
|
+
};
|
|
13
|
+
/** Shared mathjs parse — the one place expression text becomes a tree. */
|
|
14
|
+
export declare const parseExpression: (expression: string) => ParseResult;
|
|
15
|
+
/**
|
|
16
|
+
* Bases whose values are ALREADY canonical, so an equation in them needs no
|
|
17
|
+
* `resultUnit` at all (the pre-unit-annotation form: a canonical scope in
|
|
18
|
+
* µm / µm² / µm³ / degrees).
|
|
19
|
+
*/
|
|
20
|
+
export declare const CANONICAL_BASES: Set<string>;
|
|
21
|
+
export type InferenceFailureReason =
|
|
22
|
+
/** The expression does not parse. */
|
|
23
|
+
'unparseable'
|
|
24
|
+
/** A symbol is neither a mapped variable nor a mathjs constant. */
|
|
25
|
+
| 'unknown-symbol'
|
|
26
|
+
/** A function or operator whose unit behaviour we don't model. */
|
|
27
|
+
| 'unsupported-operation'
|
|
28
|
+
/** Dimensioned terms added or compared against a different dimension. */
|
|
29
|
+
| 'inconsistent-terms'
|
|
30
|
+
/** A real exponent, but one this unit vocabulary can't name (µm⁴, 1/ft). */
|
|
31
|
+
| 'unnameable-dimension';
|
|
32
|
+
export interface InferredExpressionUnit {
|
|
33
|
+
/** The dimension of the expression's raw value. */
|
|
34
|
+
dimension: FieldDimension;
|
|
35
|
+
/**
|
|
36
|
+
* The unit the raw value carries, or null when no single unit names it
|
|
37
|
+
* (mixed bases, or a canonical/yard/liter base with no entry).
|
|
38
|
+
*/
|
|
39
|
+
unit: CalculatorUnit | null;
|
|
40
|
+
/** Distinct base units among the variables carrying that dimension. */
|
|
41
|
+
bases: string[];
|
|
42
|
+
}
|
|
43
|
+
export type ExpressionUnitInference = ({
|
|
44
|
+
ok: true;
|
|
45
|
+
} & InferredExpressionUnit) | {
|
|
46
|
+
ok: false;
|
|
47
|
+
reason: InferenceFailureReason;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* The dimension and unit of an equation's raw expression value — what the
|
|
51
|
+
* number means BEFORE `resultUnit` is applied.
|
|
52
|
+
*/
|
|
53
|
+
export declare const inferExpressionUnit: (fields: readonly CalculatorField[], equation: Pick<CalculatorEquation, "expression" | "variableToFieldId" | "variableUnits">) => ExpressionUnitInference;
|
|
54
|
+
/**
|
|
55
|
+
* True when an expression's raw value is already canonical, so omitting
|
|
56
|
+
* `resultUnit` is correct rather than a 25400× mistake. Only the
|
|
57
|
+
* no-variable-units form (and dimensionless results) qualifies.
|
|
58
|
+
*/
|
|
59
|
+
export declare const producesCanonicalValue: (inferred: InferredExpressionUnit) => boolean;
|
|
60
|
+
/** Same physical unit, ignoring display form: `in` ≡ `in_frac`, `ft` ≡ `ft_in_frac`. */
|
|
61
|
+
export declare const isEquivalentUnit: (a: CalculatorUnit, b: CalculatorUnit) => boolean;
|
|
62
|
+
/**
|
|
63
|
+
* How far off a value is when it carries `actual` but was declared as
|
|
64
|
+
* `declared` — 144 for in² labelled ft². Used to make validation messages
|
|
65
|
+
* say what the author will actually see.
|
|
66
|
+
*/
|
|
67
|
+
export declare const unitScaleRatio: (declared: CalculatorUnit, actual: CalculatorUnit) => number;
|
|
68
|
+
/**
|
|
69
|
+
* The `resultUnit` an equation should carry, or null when it should be omitted
|
|
70
|
+
* (dimensionless or already-canonical result) or cannot be derived. An
|
|
71
|
+
* existing equivalent unit is preserved so `in_frac` isn't churned to `in`.
|
|
72
|
+
*/
|
|
73
|
+
export declare const deriveResultUnit: (fields: readonly CalculatorField[], equation: Pick<CalculatorEquation, "expression" | "variableToFieldId" | "variableUnits" | "resultUnit">) => CalculatorUnit | null;
|
|
74
|
+
/**
|
|
75
|
+
* Bring an equation's unit annotations back in line with the fields it
|
|
76
|
+
* references. Field edits (a dimension switch, a kind change, a deletion) are
|
|
77
|
+
* written independently of equations, so a stale `variableUnits` entry or
|
|
78
|
+
* `resultUnit` would otherwise keep converting against a unit the field no
|
|
79
|
+
* longer uses — the failure mode this whole module exists to prevent.
|
|
80
|
+
*/
|
|
81
|
+
export declare const reconcileEquationUnits: (fields: readonly CalculatorField[], equations: readonly CalculatorEquation[]) => {
|
|
82
|
+
equations: CalculatorEquation[];
|
|
83
|
+
changed: boolean;
|
|
84
|
+
};
|
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import { create, all } from 'mathjs';
|
|
2
|
+
import { Units } from '../types/firestore.js';
|
|
3
|
+
import { fieldDefaultUnit, fieldDimension } from './schema.js';
|
|
4
|
+
import { CALCULATOR_UNIT_INFO, toCanonical, unitDimension, } from './units.js';
|
|
5
|
+
const math = create(all);
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Expression unit inference.
|
|
8
|
+
//
|
|
9
|
+
// An equation's `variableUnits` / `resultUnit` (schema.ts) evaluate it in the
|
|
10
|
+
// author's display units: each variable is converted OUT of canonical before
|
|
11
|
+
// entering the scope, and the raw result is converted back FROM `resultUnit`.
|
|
12
|
+
// Nothing in the evaluator checks that `resultUnit` is the unit the expression
|
|
13
|
+
// actually produces — it is an assertion, not a conversion. Assert wrongly and
|
|
14
|
+
// the answer is silently off by a scale factor: three inch-valued variables
|
|
15
|
+
// multiplied yield in³, so declaring the result `cu_ft` overstates it 1728×,
|
|
16
|
+
// with every unit label on screen still reading correctly.
|
|
17
|
+
//
|
|
18
|
+
// This module recovers the missing information by propagating unit exponents
|
|
19
|
+
// through the expression tree. Each variable contributes its dimension's
|
|
20
|
+
// exponent (length 1, area 2, volume 3), `*` adds them, `/` subtracts, `sqrt`
|
|
21
|
+
// halves, and the result's exponent names the dimension. Pair that with the
|
|
22
|
+
// base unit the variables share and the correct `resultUnit` is fully
|
|
23
|
+
// determined — which is why the authoring UI derives it rather than asking,
|
|
24
|
+
// and why validation can flag the scale errors it otherwise cannot see.
|
|
25
|
+
//
|
|
26
|
+
// Inference is deliberately conservative. Anything it cannot reason about —
|
|
27
|
+
// unknown functions, non-integer powers, symbols with no mapping — returns
|
|
28
|
+
// `ok: false` so callers fall back to prior behaviour instead of reporting a
|
|
29
|
+
// false positive on valid math.
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
/** Constants mathjs resolves without a scope entry. */
|
|
32
|
+
export const MATHJS_CONSTANTS = new Set([
|
|
33
|
+
'pi',
|
|
34
|
+
'PI',
|
|
35
|
+
'e',
|
|
36
|
+
'E',
|
|
37
|
+
'tau',
|
|
38
|
+
'phi',
|
|
39
|
+
'i',
|
|
40
|
+
'true',
|
|
41
|
+
'false',
|
|
42
|
+
'Infinity',
|
|
43
|
+
'NaN',
|
|
44
|
+
'null',
|
|
45
|
+
]);
|
|
46
|
+
/** Shared mathjs parse — the one place expression text becomes a tree. */
|
|
47
|
+
export const parseExpression = (expression) => {
|
|
48
|
+
try {
|
|
49
|
+
return { ok: true, node: math.parse(expression) };
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
return {
|
|
53
|
+
ok: false,
|
|
54
|
+
error: err instanceof Error ? err.message : String(err),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
// The math.js base token a unit reduces to: 'sq_ft' -> 'ft', 'in_frac' -> 'in',
|
|
59
|
+
// 'liter' -> 'L'. Two units share a base exactly when they are the same
|
|
60
|
+
// physical unit at different exponents, which is what makes a result unit
|
|
61
|
+
// nameable.
|
|
62
|
+
const baseUnitOf = (unit) => (CALCULATOR_UNIT_INFO[unit]?.mathUnit ?? '').split('^')[0];
|
|
63
|
+
/**
|
|
64
|
+
* Bases whose values are ALREADY canonical, so an equation in them needs no
|
|
65
|
+
* `resultUnit` at all (the pre-unit-annotation form: a canonical scope in
|
|
66
|
+
* µm / µm² / µm³ / degrees).
|
|
67
|
+
*/
|
|
68
|
+
export const CANONICAL_BASES = new Set(['um', 'deg']);
|
|
69
|
+
const CANONICAL_LENGTH_BASE = 'um';
|
|
70
|
+
const CANONICAL_ANGLE_BASE = 'deg';
|
|
71
|
+
// Base + length exponent -> the unit that names it. Yards have no length
|
|
72
|
+
// entry (the Units enum has no yard) and liters/gallons no length base at all,
|
|
73
|
+
// so both simply fail to name a unit rather than guessing.
|
|
74
|
+
const UNIT_BY_BASE = {
|
|
75
|
+
mm: { 1: Units.Millimeters, 2: 'sq_mm', 3: 'cu_mm' },
|
|
76
|
+
cm: { 1: Units.Centimeters, 2: 'sq_cm', 3: 'cu_cm' },
|
|
77
|
+
m: { 1: Units.Meters, 2: 'sq_m', 3: 'cu_m' },
|
|
78
|
+
in: { 1: Units.Inches, 2: 'sq_in', 3: 'cu_in' },
|
|
79
|
+
ft: { 1: Units.Feet, 2: 'sq_ft', 3: 'cu_ft' },
|
|
80
|
+
yd: { 2: 'sq_yd', 3: 'cu_yd' },
|
|
81
|
+
};
|
|
82
|
+
const SCALAR = { len: 0, ang: 0 };
|
|
83
|
+
const isScalar = (e) => e.len === 0 && e.ang === 0;
|
|
84
|
+
const sameExponents = (a, b) => a.len === b.len && a.ang === b.ang;
|
|
85
|
+
const EXPONENTS_BY_DIMENSION = {
|
|
86
|
+
none: SCALAR,
|
|
87
|
+
length: { len: 1, ang: 0 },
|
|
88
|
+
area: { len: 2, ang: 0 },
|
|
89
|
+
volume: { len: 3, ang: 0 },
|
|
90
|
+
angle: { len: 0, ang: 1 },
|
|
91
|
+
};
|
|
92
|
+
// Functions that return their arguments' dimension (scalar arguments — a digit
|
|
93
|
+
// count, a clamp bound — are ignored).
|
|
94
|
+
const DIMENSION_PRESERVING_FNS = new Set([
|
|
95
|
+
'abs',
|
|
96
|
+
'min',
|
|
97
|
+
'max',
|
|
98
|
+
'mean',
|
|
99
|
+
'median',
|
|
100
|
+
'sum',
|
|
101
|
+
'round',
|
|
102
|
+
'floor',
|
|
103
|
+
'ceil',
|
|
104
|
+
'fix',
|
|
105
|
+
'mod',
|
|
106
|
+
]);
|
|
107
|
+
// Trig works in DEGREES here (evaluate.ts overrides mathjs), so an inverse
|
|
108
|
+
// function's result is an angle in the canonical angle unit.
|
|
109
|
+
const DIRECT_TRIG_FNS = new Set(['sin', 'cos', 'tan', 'sec', 'csc', 'cot']);
|
|
110
|
+
const INVERSE_TRIG_FNS = new Set(['asin', 'acos', 'atan', 'atan2']);
|
|
111
|
+
// Functions that require and return plain numbers.
|
|
112
|
+
const SCALAR_FNS = new Set(['log', 'log10', 'log2', 'exp', 'sign']);
|
|
113
|
+
/**
|
|
114
|
+
* The dimension and unit of an equation's raw expression value — what the
|
|
115
|
+
* number means BEFORE `resultUnit` is applied.
|
|
116
|
+
*/
|
|
117
|
+
export const inferExpressionUnit = (fields, equation) => {
|
|
118
|
+
const parsed = parseExpression(equation.expression);
|
|
119
|
+
if (!parsed.ok)
|
|
120
|
+
return { ok: false, reason: 'unparseable' };
|
|
121
|
+
const fieldById = new Map(fields.map((f) => [f.id, f]));
|
|
122
|
+
const lengthBases = new Set();
|
|
123
|
+
const angleBases = new Set();
|
|
124
|
+
let failure = null;
|
|
125
|
+
const fail = (reason) => {
|
|
126
|
+
failure ?? (failure = reason);
|
|
127
|
+
return null;
|
|
128
|
+
};
|
|
129
|
+
const noteBases = (exponents, base) => {
|
|
130
|
+
if (exponents.len !== 0)
|
|
131
|
+
lengthBases.add(base);
|
|
132
|
+
if (exponents.ang !== 0)
|
|
133
|
+
angleBases.add(base);
|
|
134
|
+
};
|
|
135
|
+
const resolveSymbol = (name) => {
|
|
136
|
+
const unit = equation.variableUnits?.[name];
|
|
137
|
+
if (unit != null) {
|
|
138
|
+
const exponents = EXPONENTS_BY_DIMENSION[unitDimension(unit)];
|
|
139
|
+
noteBases(exponents, baseUnitOf(unit));
|
|
140
|
+
return exponents;
|
|
141
|
+
}
|
|
142
|
+
const fieldId = equation.variableToFieldId[name];
|
|
143
|
+
const field = fieldId != null ? fieldById.get(fieldId) : undefined;
|
|
144
|
+
if (field) {
|
|
145
|
+
// No variable unit: the value enters the scope canonically (units.ts).
|
|
146
|
+
const dimension = fieldDimension(field);
|
|
147
|
+
const exponents = EXPONENTS_BY_DIMENSION[dimension];
|
|
148
|
+
noteBases(exponents, dimension === 'angle' ? CANONICAL_ANGLE_BASE : CANONICAL_LENGTH_BASE);
|
|
149
|
+
return exponents;
|
|
150
|
+
}
|
|
151
|
+
// Unmapped: fine if mathjs resolves it, otherwise validation's
|
|
152
|
+
// unmapped-symbol check owns the error and we just decline to infer.
|
|
153
|
+
return MATHJS_CONSTANTS.has(name) ? SCALAR : fail('unknown-symbol');
|
|
154
|
+
};
|
|
155
|
+
// Scale a term's exponents by a constant power, e.g. `^2` or `sqrt`. Only
|
|
156
|
+
// whole-number results are nameable: sqrt of an area is a length, sqrt of a
|
|
157
|
+
// length is not anything we can express.
|
|
158
|
+
const scaled = (exponents, factor) => {
|
|
159
|
+
const len = exponents.len * factor;
|
|
160
|
+
const ang = exponents.ang * factor;
|
|
161
|
+
return Number.isInteger(len) && Number.isInteger(ang)
|
|
162
|
+
? { len, ang }
|
|
163
|
+
: fail('unnameable-dimension');
|
|
164
|
+
};
|
|
165
|
+
const constantValue = (node) => {
|
|
166
|
+
if (node.type === 'ParenthesisNode' && node.content) {
|
|
167
|
+
return constantValue(node.content);
|
|
168
|
+
}
|
|
169
|
+
if (node.type === 'ConstantNode' && typeof node.value === 'number') {
|
|
170
|
+
return node.value;
|
|
171
|
+
}
|
|
172
|
+
if (node.type === 'OperatorNode' && node.fn === 'unaryMinus') {
|
|
173
|
+
const inner = node.args?.[0] ? constantValue(node.args[0]) : null;
|
|
174
|
+
return inner == null ? null : -inner;
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
};
|
|
178
|
+
const walk = (node) => {
|
|
179
|
+
switch (node.type) {
|
|
180
|
+
case 'ConstantNode':
|
|
181
|
+
return SCALAR;
|
|
182
|
+
case 'ParenthesisNode':
|
|
183
|
+
return node.content
|
|
184
|
+
? walk(node.content)
|
|
185
|
+
: fail('unsupported-operation');
|
|
186
|
+
case 'SymbolNode':
|
|
187
|
+
return node.name ? resolveSymbol(node.name) : fail('unknown-symbol');
|
|
188
|
+
case 'OperatorNode':
|
|
189
|
+
return walkOperator(node);
|
|
190
|
+
case 'FunctionNode':
|
|
191
|
+
return walkFunction(node);
|
|
192
|
+
default:
|
|
193
|
+
// ConditionalNode, AssignmentNode, ranges, matrices, ...
|
|
194
|
+
return fail('unsupported-operation');
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
const walkArgs = (node) => {
|
|
198
|
+
const args = node.args ?? [];
|
|
199
|
+
const out = [];
|
|
200
|
+
for (const arg of args) {
|
|
201
|
+
const exponents = walk(arg);
|
|
202
|
+
if (!exponents)
|
|
203
|
+
return null;
|
|
204
|
+
out.push(exponents);
|
|
205
|
+
}
|
|
206
|
+
return out;
|
|
207
|
+
};
|
|
208
|
+
const walkOperator = (node) => {
|
|
209
|
+
const args = walkArgs(node);
|
|
210
|
+
if (!args)
|
|
211
|
+
return null;
|
|
212
|
+
switch (node.fn) {
|
|
213
|
+
case 'multiply':
|
|
214
|
+
return args.reduce((acc, e) => ({ len: acc.len + e.len, ang: acc.ang + e.ang }), SCALAR);
|
|
215
|
+
case 'divide':
|
|
216
|
+
return args.length === 2
|
|
217
|
+
? {
|
|
218
|
+
len: args[0].len - args[1].len,
|
|
219
|
+
ang: args[0].ang - args[1].ang,
|
|
220
|
+
}
|
|
221
|
+
: fail('unsupported-operation');
|
|
222
|
+
case 'add':
|
|
223
|
+
case 'subtract': {
|
|
224
|
+
// A bare literal added to a dimensioned term adopts its unit — that is
|
|
225
|
+
// the whole point of evaluating in display units ("wall + 2 inches").
|
|
226
|
+
// Two DIFFERENT dimensions added is an author error.
|
|
227
|
+
const dimensioned = args.filter((e) => !isScalar(e));
|
|
228
|
+
if (dimensioned.length === 0)
|
|
229
|
+
return SCALAR;
|
|
230
|
+
return dimensioned.every((e) => sameExponents(e, dimensioned[0]))
|
|
231
|
+
? dimensioned[0]
|
|
232
|
+
: fail('inconsistent-terms');
|
|
233
|
+
}
|
|
234
|
+
case 'unaryMinus':
|
|
235
|
+
case 'unaryPlus':
|
|
236
|
+
return args[0] ?? fail('unsupported-operation');
|
|
237
|
+
case 'pow': {
|
|
238
|
+
if (args.length !== 2)
|
|
239
|
+
return fail('unsupported-operation');
|
|
240
|
+
if (isScalar(args[0]))
|
|
241
|
+
return SCALAR;
|
|
242
|
+
const exponent = node.args?.[1] ? constantValue(node.args[1]) : null;
|
|
243
|
+
return exponent == null
|
|
244
|
+
? fail('unsupported-operation')
|
|
245
|
+
: scaled(args[0], exponent);
|
|
246
|
+
}
|
|
247
|
+
case 'mod':
|
|
248
|
+
return args[0] ?? fail('unsupported-operation');
|
|
249
|
+
default:
|
|
250
|
+
// Comparisons, logic, bit ops — not meaningful on dimensioned values.
|
|
251
|
+
return fail('unsupported-operation');
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
const walkFunction = (node) => {
|
|
255
|
+
const args = walkArgs(node);
|
|
256
|
+
if (!args)
|
|
257
|
+
return null;
|
|
258
|
+
const name = typeof node.fn === 'string' ? node.fn : node.fn?.name;
|
|
259
|
+
if (!name)
|
|
260
|
+
return fail('unsupported-operation');
|
|
261
|
+
if (name === 'sqrt')
|
|
262
|
+
return scaled(args[0] ?? SCALAR, 0.5);
|
|
263
|
+
if (name === 'cbrt')
|
|
264
|
+
return scaled(args[0] ?? SCALAR, 1 / 3);
|
|
265
|
+
if (name === 'nthRoot') {
|
|
266
|
+
const root = node.args?.[1] ? constantValue(node.args[1]) : 2;
|
|
267
|
+
return root == null || root === 0
|
|
268
|
+
? fail('unsupported-operation')
|
|
269
|
+
: scaled(args[0] ?? SCALAR, 1 / root);
|
|
270
|
+
}
|
|
271
|
+
if (DIMENSION_PRESERVING_FNS.has(name)) {
|
|
272
|
+
const dimensioned = args.filter((e) => !isScalar(e));
|
|
273
|
+
if (dimensioned.length === 0)
|
|
274
|
+
return SCALAR;
|
|
275
|
+
return dimensioned.every((e) => sameExponents(e, dimensioned[0]))
|
|
276
|
+
? dimensioned[0]
|
|
277
|
+
: fail('inconsistent-terms');
|
|
278
|
+
}
|
|
279
|
+
if (DIRECT_TRIG_FNS.has(name)) {
|
|
280
|
+
// Degrees in, plain ratio out.
|
|
281
|
+
const arg = args[0] ?? SCALAR;
|
|
282
|
+
return arg.len === 0 && arg.ang <= 1
|
|
283
|
+
? SCALAR
|
|
284
|
+
: fail('inconsistent-terms');
|
|
285
|
+
}
|
|
286
|
+
if (INVERSE_TRIG_FNS.has(name)) {
|
|
287
|
+
// atan2's two arguments cancel, so they only need to match.
|
|
288
|
+
if (!args.every((e) => sameExponents(e, args[0] ?? SCALAR))) {
|
|
289
|
+
return fail('inconsistent-terms');
|
|
290
|
+
}
|
|
291
|
+
angleBases.add(CANONICAL_ANGLE_BASE);
|
|
292
|
+
return { len: 0, ang: 1 };
|
|
293
|
+
}
|
|
294
|
+
if (SCALAR_FNS.has(name)) {
|
|
295
|
+
return args.every(isScalar) ? SCALAR : fail('inconsistent-terms');
|
|
296
|
+
}
|
|
297
|
+
return fail('unsupported-operation');
|
|
298
|
+
};
|
|
299
|
+
const exponents = walk(parsed.node);
|
|
300
|
+
if (!exponents) {
|
|
301
|
+
return { ok: false, reason: failure ?? 'unsupported-operation' };
|
|
302
|
+
}
|
|
303
|
+
if (exponents.ang !== 0) {
|
|
304
|
+
// Angles don't combine with lengths into anything nameable (a length·deg
|
|
305
|
+
// has no unit here), and neither does deg².
|
|
306
|
+
if (exponents.len !== 0 || exponents.ang !== 1) {
|
|
307
|
+
return { ok: false, reason: 'unnameable-dimension' };
|
|
308
|
+
}
|
|
309
|
+
const bases = [...angleBases];
|
|
310
|
+
return {
|
|
311
|
+
ok: true,
|
|
312
|
+
dimension: 'angle',
|
|
313
|
+
unit: bases.length === 1 ? bases[0] : null,
|
|
314
|
+
bases,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
if (exponents.len === 0) {
|
|
318
|
+
return { ok: true, dimension: 'none', unit: null, bases: [] };
|
|
319
|
+
}
|
|
320
|
+
if (exponents.len < 1 || exponents.len > 3) {
|
|
321
|
+
return { ok: false, reason: 'unnameable-dimension' };
|
|
322
|
+
}
|
|
323
|
+
const dimension = exponents.len === 1 ? 'length' : exponents.len === 2 ? 'area' : 'volume';
|
|
324
|
+
const bases = [...lengthBases];
|
|
325
|
+
return {
|
|
326
|
+
ok: true,
|
|
327
|
+
dimension,
|
|
328
|
+
unit: bases.length === 1
|
|
329
|
+
? (UNIT_BY_BASE[bases[0]]?.[exponents.len] ?? null)
|
|
330
|
+
: null,
|
|
331
|
+
bases,
|
|
332
|
+
};
|
|
333
|
+
};
|
|
334
|
+
/**
|
|
335
|
+
* True when an expression's raw value is already canonical, so omitting
|
|
336
|
+
* `resultUnit` is correct rather than a 25400× mistake. Only the
|
|
337
|
+
* no-variable-units form (and dimensionless results) qualifies.
|
|
338
|
+
*/
|
|
339
|
+
export const producesCanonicalValue = (inferred) => inferred.dimension === 'none' ||
|
|
340
|
+
(inferred.bases.length > 0 &&
|
|
341
|
+
inferred.bases.every((b) => CANONICAL_BASES.has(b)));
|
|
342
|
+
/** Same physical unit, ignoring display form: `in` ≡ `in_frac`, `ft` ≡ `ft_in_frac`. */
|
|
343
|
+
export const isEquivalentUnit = (a, b) => a === b ||
|
|
344
|
+
(unitDimension(a) === unitDimension(b) && baseUnitOf(a) === baseUnitOf(b));
|
|
345
|
+
/**
|
|
346
|
+
* How far off a value is when it carries `actual` but was declared as
|
|
347
|
+
* `declared` — 144 for in² labelled ft². Used to make validation messages
|
|
348
|
+
* say what the author will actually see.
|
|
349
|
+
*/
|
|
350
|
+
export const unitScaleRatio = (declared, actual) => toCanonical(1, declared) / toCanonical(1, actual);
|
|
351
|
+
/**
|
|
352
|
+
* The `resultUnit` an equation should carry, or null when it should be omitted
|
|
353
|
+
* (dimensionless or already-canonical result) or cannot be derived. An
|
|
354
|
+
* existing equivalent unit is preserved so `in_frac` isn't churned to `in`.
|
|
355
|
+
*/
|
|
356
|
+
export const deriveResultUnit = (fields, equation) => {
|
|
357
|
+
const inferred = inferExpressionUnit(fields, equation);
|
|
358
|
+
if (!inferred.ok || inferred.unit == null)
|
|
359
|
+
return null;
|
|
360
|
+
if (producesCanonicalValue(inferred))
|
|
361
|
+
return null;
|
|
362
|
+
const current = equation.resultUnit;
|
|
363
|
+
return current != null && isEquivalentUnit(current, inferred.unit)
|
|
364
|
+
? current
|
|
365
|
+
: inferred.unit;
|
|
366
|
+
};
|
|
367
|
+
// Rebuild an equation without a key, since Firestore rejects `undefined`.
|
|
368
|
+
const withoutResultUnit = (equation) => {
|
|
369
|
+
const { resultUnit: _dropped, ...rest } = equation;
|
|
370
|
+
return rest;
|
|
371
|
+
};
|
|
372
|
+
/**
|
|
373
|
+
* Bring an equation's unit annotations back in line with the fields it
|
|
374
|
+
* references. Field edits (a dimension switch, a kind change, a deletion) are
|
|
375
|
+
* written independently of equations, so a stale `variableUnits` entry or
|
|
376
|
+
* `resultUnit` would otherwise keep converting against a unit the field no
|
|
377
|
+
* longer uses — the failure mode this whole module exists to prevent.
|
|
378
|
+
*/
|
|
379
|
+
export const reconcileEquationUnits = (fields, equations) => {
|
|
380
|
+
const fieldById = new Map(fields.map((f) => [f.id, f]));
|
|
381
|
+
let changed = false;
|
|
382
|
+
const next = equations.map((equation) => {
|
|
383
|
+
let repaired = equation;
|
|
384
|
+
// 1. Variable units must match the dimension of the field they annotate.
|
|
385
|
+
if (equation.variableUnits) {
|
|
386
|
+
const variableUnits = {};
|
|
387
|
+
let unitsChanged = false;
|
|
388
|
+
for (const [variable, unit] of Object.entries(equation.variableUnits)) {
|
|
389
|
+
const field = fieldById.get(equation.variableToFieldId[variable] ?? '');
|
|
390
|
+
if (!field) {
|
|
391
|
+
unitsChanged = true; // variable or field is gone
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
if (unitDimension(unit) === fieldDimension(field)) {
|
|
395
|
+
variableUnits[variable] = unit;
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
const replacement = fieldDefaultUnit(field);
|
|
399
|
+
unitsChanged = true;
|
|
400
|
+
if (replacement)
|
|
401
|
+
variableUnits[variable] = replacement;
|
|
402
|
+
}
|
|
403
|
+
if (unitsChanged) {
|
|
404
|
+
repaired =
|
|
405
|
+
Object.keys(variableUnits).length > 0
|
|
406
|
+
? { ...repaired, variableUnits }
|
|
407
|
+
: (() => {
|
|
408
|
+
const { variableUnits: _dropped, ...rest } = repaired;
|
|
409
|
+
return rest;
|
|
410
|
+
})();
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
// 2. The result unit follows from the repaired variable units.
|
|
414
|
+
const target = fieldById.get(equation.targetFieldId);
|
|
415
|
+
const targetDimension = target ? fieldDimension(target) : 'none';
|
|
416
|
+
if (targetDimension === 'none') {
|
|
417
|
+
if (repaired.resultUnit != null)
|
|
418
|
+
repaired = withoutResultUnit(repaired);
|
|
419
|
+
}
|
|
420
|
+
else {
|
|
421
|
+
const derived = deriveResultUnit(fields, repaired);
|
|
422
|
+
if (derived != null) {
|
|
423
|
+
if (repaired.resultUnit !== derived) {
|
|
424
|
+
repaired = { ...repaired, resultUnit: derived };
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
else if (repaired.resultUnit != null &&
|
|
428
|
+
unitDimension(repaired.resultUnit) !== targetDimension) {
|
|
429
|
+
// Not derivable AND not even the right dimension: drop it rather than
|
|
430
|
+
// keep converting a length factor into an area field.
|
|
431
|
+
repaired = withoutResultUnit(repaired);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
if (repaired !== equation)
|
|
435
|
+
changed = true;
|
|
436
|
+
return repaired;
|
|
437
|
+
});
|
|
438
|
+
return { equations: next, changed };
|
|
439
|
+
};
|
package/dist/calculator/index.js
CHANGED
|
@@ -111,6 +111,18 @@ export interface CalculatorEquation {
|
|
|
111
111
|
expression: string;
|
|
112
112
|
/** Variable letter -> field id. */
|
|
113
113
|
variableToFieldId: Record<string, string>;
|
|
114
|
+
/**
|
|
115
|
+
* Variable letter -> display unit its value enters the scope in. A mapped
|
|
116
|
+
* variable without an entry stays canonical (µm-scale) — the pre-1.9
|
|
117
|
+
* behavior. Unit dimension must match the mapped field's dimension.
|
|
118
|
+
*/
|
|
119
|
+
variableUnits?: Record<string, CalculatorUnit>;
|
|
120
|
+
/**
|
|
121
|
+
* Display unit the expression's RESULT is expressed in; converted back to
|
|
122
|
+
* canonical before storage/chaining. Only meaningful for measurement/angle
|
|
123
|
+
* targets — number targets take the result raw. Absent = canonical.
|
|
124
|
+
*/
|
|
125
|
+
resultUnit?: CalculatorUnit;
|
|
114
126
|
}
|
|
115
127
|
/** A runtime/preview value for one field, in canonical units. */
|
|
116
128
|
export type CalculatorFieldValue = number | string | string[] | null;
|
|
@@ -159,6 +171,12 @@ export declare const findEquation: (definition: Pick<CalculatorDefinition, "equa
|
|
|
159
171
|
export declare const equationForField: (definition: Pick<CalculatorDefinition, "equations">, fieldId: string) => CalculatorEquation | undefined;
|
|
160
172
|
/** The dimension a field's numeric value carries. */
|
|
161
173
|
export declare const fieldDimension: (field: CalculatorField) => FieldDimension;
|
|
174
|
+
/**
|
|
175
|
+
* The unit a field's value is entered and displayed in, or null for fields
|
|
176
|
+
* that carry no unit. Also the unit an equation referencing the field starts
|
|
177
|
+
* out annotated with.
|
|
178
|
+
*/
|
|
179
|
+
export declare const fieldDefaultUnit: (field: CalculatorField) => CalculatorUnit | null;
|
|
162
180
|
/** Fields whose value participates in equations as a number. */
|
|
163
181
|
export declare const isNumericFieldKind: (kind: CalculatorFieldKind) => kind is ColumnType.Number | ColumnType.Measurement | ColumnType.Angle | ColumnType.ConversionTable;
|
|
164
182
|
/** Field kinds an equation may target (compute into). */
|
|
@@ -13,11 +13,17 @@ import { unitsForDimension } from './units.js';
|
|
|
13
13
|
// brackets) and every runtime field value is canonical — µm for length, µm²
|
|
14
14
|
// area, µm³ volume, degrees for angle, raw for dimensionless (see units.ts).
|
|
15
15
|
//
|
|
16
|
-
// EXPRESSION SEMANTICS: equations are math.js expressions
|
|
17
|
-
// canonical scope
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
16
|
+
// EXPRESSION SEMANTICS: equations are math.js expressions. By default they
|
|
17
|
+
// evaluate over a canonical scope, which makes numeric literals canonical too
|
|
18
|
+
// (a bare `2` multiplying a length is a plain scalar; a bare additive length
|
|
19
|
+
// constant would be micrometers). Per-equation `variableUnits` / `resultUnit`
|
|
20
|
+
// instead evaluate in author-chosen display units: each mapped variable is
|
|
21
|
+
// converted OUT of canonical into its unit before entering the scope, and the
|
|
22
|
+
// result is converted back FROM `resultUnit` into canonical. This is what
|
|
23
|
+
// makes literals meaningful ("roof area in ft² / 100 = roofing squares") —
|
|
24
|
+
// without it, a number-kind output consuming a measurement variable silently
|
|
25
|
+
// receives µm-scale values. The authoring UI always sets units; the bare
|
|
26
|
+
// canonical form survives for pre-existing definitions.
|
|
21
27
|
// ---------------------------------------------------------------------------
|
|
22
28
|
/**
|
|
23
29
|
* Version of the definition format itself. Bumped only on breaking schema
|
|
@@ -53,6 +59,21 @@ export const fieldDimension = (field) => {
|
|
|
53
59
|
return 'none';
|
|
54
60
|
}
|
|
55
61
|
};
|
|
62
|
+
/**
|
|
63
|
+
* The unit a field's value is entered and displayed in, or null for fields
|
|
64
|
+
* that carry no unit. Also the unit an equation referencing the field starts
|
|
65
|
+
* out annotated with.
|
|
66
|
+
*/
|
|
67
|
+
export const fieldDefaultUnit = (field) => {
|
|
68
|
+
switch (field.kind) {
|
|
69
|
+
case ColumnType.Measurement:
|
|
70
|
+
return field.unit.defaultUnit;
|
|
71
|
+
case ColumnType.Angle:
|
|
72
|
+
return field.angleUnit ?? 'deg';
|
|
73
|
+
default:
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
56
77
|
/** Fields whose value participates in equations as a number. */
|
|
57
78
|
export const isNumericFieldKind = (kind) => kind === ColumnType.Number ||
|
|
58
79
|
kind === ColumnType.Measurement ||
|
|
@@ -157,6 +157,8 @@ export declare const calculatorDefinitionSchema: z.ZodObject<{
|
|
|
157
157
|
targetFieldId: z.ZodString;
|
|
158
158
|
expression: z.ZodString;
|
|
159
159
|
variableToFieldId: z.ZodRecord<z.ZodString, z.ZodString>;
|
|
160
|
+
variableUnits: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
161
|
+
resultUnit: z.ZodOptional<z.ZodString>;
|
|
160
162
|
}, z.core.$strip>>;
|
|
161
163
|
diagramFileId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
162
164
|
isPublic: z.ZodBoolean;
|
|
@@ -182,8 +184,16 @@ export interface ValidationResult {
|
|
|
182
184
|
ok: boolean;
|
|
183
185
|
issues: ValidationIssue[];
|
|
184
186
|
}
|
|
185
|
-
/**
|
|
186
|
-
|
|
187
|
+
/**
|
|
188
|
+
* Free variables of an expression (function names and constants excluded).
|
|
189
|
+
*
|
|
190
|
+
* `knownVariables` are treated as variables even when mathjs would resolve
|
|
191
|
+
* them as constants: the authoring UI names variables A, B, C… and `E` is
|
|
192
|
+
* Euler's number, so without this a mapped `E` reads as a constant and gets
|
|
193
|
+
* reported as an unused variable. Evaluation is unaffected — a scope entry
|
|
194
|
+
* shadows the constant — so this only realigns validation with reality.
|
|
195
|
+
*/
|
|
196
|
+
export declare const expressionSymbols: (expression: string, knownVariables?: Iterable<string>) => {
|
|
187
197
|
ok: true;
|
|
188
198
|
symbols: string[];
|
|
189
199
|
} | {
|