@reekon-tools/boldr-utils 1.9.6 → 1.10.1
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/conversionTable.d.ts +81 -0
- package/dist/calculator/conversionTable.js +170 -0
- package/dist/calculator/evaluate.d.ts +5 -1
- package/dist/calculator/evaluate.js +67 -18
- package/dist/calculator/expressionUnits.d.ts +103 -8
- package/dist/calculator/expressionUnits.js +318 -63
- package/dist/calculator/index.d.ts +1 -0
- package/dist/calculator/index.js +1 -0
- package/dist/calculator/schema.d.ts +87 -4
- package/dist/calculator/schema.js +61 -1
- package/dist/calculator/units.d.ts +27 -0
- package/dist/calculator/units.js +47 -0
- package/dist/calculator/validate.d.ts +14 -0
- package/dist/calculator/validate.js +128 -14
- package/dist/types/firestore.d.ts +41 -2
- package/package.json +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ColumnType, Units, } from '../types/firestore.js';
|
|
2
|
-
import { unitsForDimension } from './units.js';
|
|
2
|
+
import { unitForBase, unitsForDimension } from './units.js';
|
|
3
|
+
import { conversionColumnDimension, resolveConversionColumn, } from './conversionTable.js';
|
|
3
4
|
// ---------------------------------------------------------------------------
|
|
4
5
|
// The Construction Calculator definition — the shared artifact the web
|
|
5
6
|
// authoring tool produces, the mobile runtime interprets, and sync ships.
|
|
@@ -59,6 +60,31 @@ export const fieldDimension = (field) => {
|
|
|
59
60
|
return 'none';
|
|
60
61
|
}
|
|
61
62
|
};
|
|
63
|
+
/** The binding for one variable letter, or null when the letter is unmapped. */
|
|
64
|
+
export const equationBinding = (equation, variable) => {
|
|
65
|
+
const fieldId = equation.variableToFieldId[variable];
|
|
66
|
+
if (fieldId == null)
|
|
67
|
+
return null;
|
|
68
|
+
const columnId = equation.variableColumnIds?.[variable];
|
|
69
|
+
return columnId != null ? { fieldId, columnId } : { fieldId };
|
|
70
|
+
};
|
|
71
|
+
/** Every variable's binding, in the variable map's order. */
|
|
72
|
+
export const equationBindings = (equation) => Object.keys(equation.variableToFieldId).map((variable) => ({
|
|
73
|
+
variable,
|
|
74
|
+
fieldId: equation.variableToFieldId[variable],
|
|
75
|
+
...(equation.variableColumnIds?.[variable] != null
|
|
76
|
+
? { columnId: equation.variableColumnIds[variable] }
|
|
77
|
+
: {}),
|
|
78
|
+
}));
|
|
79
|
+
/**
|
|
80
|
+
* The dimension a BOUND value carries. Same as `fieldDimension` for every kind
|
|
81
|
+
* but a conversion table, where a column may declare a unit and so give the
|
|
82
|
+
* binding a real dimension — the thing that lets `width · depth` off one table
|
|
83
|
+
* row infer as an area rather than a bare product.
|
|
84
|
+
*/
|
|
85
|
+
export const bindingDimension = (field, columnId) => field.kind === ColumnType.ConversionTable
|
|
86
|
+
? conversionColumnDimension(resolveConversionColumn(field.columnData, columnId))
|
|
87
|
+
: fieldDimension(field);
|
|
62
88
|
/**
|
|
63
89
|
* The unit a field's value is entered and displayed in, or null for fields
|
|
64
90
|
* that carry no unit. Also the unit an equation referencing the field starts
|
|
@@ -74,6 +100,40 @@ export const fieldDefaultUnit = (field) => {
|
|
|
74
100
|
return null;
|
|
75
101
|
}
|
|
76
102
|
};
|
|
103
|
+
/**
|
|
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.
|
|
108
|
+
*/
|
|
109
|
+
export const bindingDefaultUnit = (field, columnId) => field.kind === ColumnType.ConversionTable
|
|
110
|
+
? (resolveConversionColumn(field.columnData, columnId).unit ?? null)
|
|
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
|
+
};
|
|
77
137
|
/** Fields whose value participates in equations as a number. */
|
|
78
138
|
export const isNumericFieldKind = (kind) => kind === ColumnType.Number ||
|
|
79
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
|
|
@@ -121,7 +121,13 @@ export declare const calculatorDefinitionSchema: z.ZodObject<{
|
|
|
121
121
|
conversions: z.ZodArray<z.ZodObject<{
|
|
122
122
|
label: z.ZodString;
|
|
123
123
|
value: z.ZodString;
|
|
124
|
+
values: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
124
125
|
}, z.core.$strip>>;
|
|
126
|
+
columns: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
127
|
+
id: z.ZodString;
|
|
128
|
+
name: z.ZodString;
|
|
129
|
+
unit: z.ZodOptional<z.ZodString>;
|
|
130
|
+
}, z.core.$strip>>>;
|
|
125
131
|
}, z.core.$strip>;
|
|
126
132
|
defaultValue: z.ZodOptional<z.ZodString>;
|
|
127
133
|
id: z.ZodString;
|
|
@@ -157,6 +163,14 @@ export declare const calculatorDefinitionSchema: z.ZodObject<{
|
|
|
157
163
|
targetFieldId: z.ZodString;
|
|
158
164
|
expression: z.ZodString;
|
|
159
165
|
variableToFieldId: z.ZodRecord<z.ZodString, z.ZodString>;
|
|
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
|
+
}>>;
|
|
160
174
|
variableUnits: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
161
175
|
resultUnit: z.ZodOptional<z.ZodString>;
|
|
162
176
|
}, z.core.$strip>>;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { ColumnType, DecimalTolerance, FractionalTolerance, } from '../types/firestore.js';
|
|
3
|
-
import { CALCULATOR_SCHEMA_VERSION, equationForField, fieldDimension, isEquationTargetKind, isNumericFieldKind, } from './schema.js';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
3
|
+
import { CALCULATOR_SCHEMA_VERSION, bindingDimension, equationForField, fieldDimension, isEquationTargetKind, isNumericFieldKind, } from './schema.js';
|
|
4
|
+
import { conversionBindingLabel, conversionCellValue, conversionColumnLabel, conversionColumns, findConversionColumn, hasMultipleConversionColumns, } from './conversionTable.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';
|
|
6
7
|
import { isCalculatorCategoryId } from './categories.js';
|
|
7
8
|
// ---------------------------------------------------------------------------
|
|
8
9
|
// Definition validation: zod for structure, plus the cross-field invariants
|
|
@@ -36,7 +37,18 @@ const fieldBase = {
|
|
|
36
37
|
};
|
|
37
38
|
const selectColumnDataSchema = z.object({ options: z.array(z.string()) });
|
|
38
39
|
const conversionTableColumnDataSchema = z.object({
|
|
39
|
-
conversions: z.array(z.object({
|
|
40
|
+
conversions: z.array(z.object({
|
|
41
|
+
label: z.string(),
|
|
42
|
+
value: z.string(),
|
|
43
|
+
values: z.record(z.string(), z.string()).optional(),
|
|
44
|
+
})),
|
|
45
|
+
columns: z
|
|
46
|
+
.array(z.object({
|
|
47
|
+
id: z.string().min(1),
|
|
48
|
+
name: z.string(),
|
|
49
|
+
unit: calculatorUnitSchema.optional(),
|
|
50
|
+
}))
|
|
51
|
+
.optional(),
|
|
40
52
|
});
|
|
41
53
|
const instructionsColumnDataSchema = z.object({
|
|
42
54
|
text: z.string(),
|
|
@@ -93,6 +105,8 @@ const calculatorEquationSchema = z.object({
|
|
|
93
105
|
targetFieldId: z.string().min(1),
|
|
94
106
|
expression: z.string().min(1),
|
|
95
107
|
variableToFieldId: z.record(z.string(), z.string()),
|
|
108
|
+
variableColumnIds: z.record(z.string(), z.string()).optional(),
|
|
109
|
+
base: z.enum(EQUATION_BASES).optional(),
|
|
96
110
|
variableUnits: z.record(z.string(), calculatorUnitSchema).optional(),
|
|
97
111
|
resultUnit: calculatorUnitSchema.optional(),
|
|
98
112
|
});
|
|
@@ -163,6 +177,12 @@ const DIMENSION_LABELS = {
|
|
|
163
177
|
none: 'a plain number',
|
|
164
178
|
};
|
|
165
179
|
const describeDimension = (dimension) => DIMENSION_LABELS[dimension];
|
|
180
|
+
// How a message names what a variable is bound to. A multi-column conversion
|
|
181
|
+
// table needs the column too — "maps to Lumber" is useless when the author has
|
|
182
|
+
// three columns and only one of them is wrong.
|
|
183
|
+
const describeBinding = (field, columnId) => field.kind === ColumnType.ConversionTable
|
|
184
|
+
? `"${conversionBindingLabel(field.name, field.columnData, columnId)}"`
|
|
185
|
+
: `"${field.name}"`;
|
|
166
186
|
// 1728 stays 1728; 57.29577951308232 becomes 57.3.
|
|
167
187
|
const trimRatio = (ratio) => Number.isInteger(ratio) ? String(ratio) : ratio.toFixed(1);
|
|
168
188
|
// Does `equation` depend on `fieldId`, directly or through the equations of
|
|
@@ -259,9 +279,37 @@ export const validateCalculatorDefinition = (input) => {
|
|
|
259
279
|
if (conversions.length === 0) {
|
|
260
280
|
push('empty-conversions', `${path}.columnData.conversions`, 'Conversion table is empty');
|
|
261
281
|
}
|
|
282
|
+
// An explicit `columns` array is only ever written by the multi-column
|
|
283
|
+
// editor; a single-column table has none and normalizes to the implicit
|
|
284
|
+
// column, so these checks are no-ops for every table authored before now.
|
|
285
|
+
const columns = conversionColumns(field.columnData);
|
|
286
|
+
const columnIds = new Set();
|
|
287
|
+
columns.forEach((column, j) => {
|
|
288
|
+
if (columnIds.has(column.id)) {
|
|
289
|
+
push('duplicate-conversion-column-id', `${path}.columnData.columns.${j}.id`, `Duplicate conversion column id "${column.id}"`);
|
|
290
|
+
}
|
|
291
|
+
columnIds.add(column.id);
|
|
292
|
+
});
|
|
293
|
+
const columnNames = columns
|
|
294
|
+
.map((c) => c.name.trim())
|
|
295
|
+
.filter((n) => n !== '');
|
|
296
|
+
const dupeNames = columnNames.filter((n, j) => columnNames.indexOf(n) !== j);
|
|
297
|
+
if (dupeNames.length > 0) {
|
|
298
|
+
push('duplicate-conversion-column-names', `${path}.columnData.columns`, `Two value columns are both called ${[...new Set(dupeNames)].map((n) => `"${n}"`).join(', ')} — formula chips for them will be indistinguishable`, 'warning');
|
|
299
|
+
}
|
|
262
300
|
conversions.forEach((c, j) => {
|
|
263
|
-
|
|
264
|
-
|
|
301
|
+
for (const column of columns) {
|
|
302
|
+
const cell = conversionCellValue(field.columnData, c, column.id);
|
|
303
|
+
if (Number.isFinite(parseFloat(cell)))
|
|
304
|
+
continue;
|
|
305
|
+
// Path stays on `.value` for the implicit/first column so existing
|
|
306
|
+
// consumers of this issue path keep resolving to the same input.
|
|
307
|
+
const cellPath = column.id === columns[0].id
|
|
308
|
+
? `${path}.columnData.conversions.${j}.value`
|
|
309
|
+
: `${path}.columnData.conversions.${j}.values.${column.id}`;
|
|
310
|
+
push('conversion-value-not-numeric', cellPath, columns.length > 1
|
|
311
|
+
? `Conversion "${c.label}" has a non-numeric value "${cell}" in column "${conversionColumnLabel(field.columnData, column.id)}"`
|
|
312
|
+
: `Conversion "${c.label}" has non-numeric value "${cell}"`);
|
|
265
313
|
}
|
|
266
314
|
});
|
|
267
315
|
const labels = conversions.map((c) => c.label);
|
|
@@ -298,10 +346,39 @@ export const validateCalculatorDefinition = (input) => {
|
|
|
298
346
|
else if (!isNumericFieldKind(mapped.kind)) {
|
|
299
347
|
push('non-numeric-mapped-field', `${path}.variableToFieldId.${variable}`, `Variable ${variable} maps to "${mapped.name}", which has no numeric value`);
|
|
300
348
|
}
|
|
349
|
+
else if (mapped.kind === ColumnType.ConversionTable &&
|
|
350
|
+
hasMultipleConversionColumns(mapped.columnData) &&
|
|
351
|
+
eq.variableColumnIds?.[variable] == null) {
|
|
352
|
+
// Not an error: the evaluator reads the first column, which is what
|
|
353
|
+
// this binding meant before the table grew a second one. But it is
|
|
354
|
+
// very likely not what the author now wants, so say so.
|
|
355
|
+
push('conversion-column-unspecified', `${path}.variableToFieldId.${variable}`, `Variable ${variable} maps to "${mapped.name}", which has ${conversionColumns(mapped.columnData).length} value columns, but names none — it reads "${conversionColumnLabel(mapped.columnData)}". Re-insert the chip for the column you want.`, 'warning');
|
|
356
|
+
}
|
|
301
357
|
if (fieldId === eq.targetFieldId) {
|
|
302
358
|
push('self-referencing-equation', `${path}.variableToFieldId.${variable}`, `Equation for "${eq.targetFieldId}" references its own target`);
|
|
303
359
|
}
|
|
304
360
|
}
|
|
361
|
+
// --- Column bindings ---------------------------------------------------
|
|
362
|
+
for (const [variable, columnId] of Object.entries(eq.variableColumnIds ?? {})) {
|
|
363
|
+
const columnPath = `${path}.variableColumnIds.${variable}`;
|
|
364
|
+
const mappedId = eq.variableToFieldId[variable];
|
|
365
|
+
if (mappedId == null) {
|
|
366
|
+
push('column-for-unmapped-variable', columnPath, `Variable ${variable} names a conversion column but has no field mapping`, 'warning');
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
const mapped = fieldById.get(mappedId);
|
|
370
|
+
if (!mapped)
|
|
371
|
+
continue; // unknown-mapped-field already reported above
|
|
372
|
+
if (mapped.kind !== ColumnType.ConversionTable) {
|
|
373
|
+
push('column-on-non-conversion-field', columnPath, `Variable ${variable} names a conversion column but maps to "${mapped.name}", which is not a conversion table`, 'warning');
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
if (!findConversionColumn(mapped.columnData, columnId)) {
|
|
377
|
+
// The evaluator falls back to the first column, so the equation still
|
|
378
|
+
// computes — with a value the author never asked for. An error.
|
|
379
|
+
push('unknown-conversion-column', columnPath, `Variable ${variable} reads column "${columnId}" of "${mapped.name}", which no longer exists — it will fall back to "${conversionColumnLabel(mapped.columnData)}"`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
305
382
|
// --- Unit annotations --------------------------------------------------
|
|
306
383
|
for (const [variable, unit] of Object.entries(eq.variableUnits ?? {})) {
|
|
307
384
|
const unitPath = `${path}.variableUnits.${variable}`;
|
|
@@ -313,18 +390,27 @@ export const validateCalculatorDefinition = (input) => {
|
|
|
313
390
|
const mapped = fieldById.get(mappedId);
|
|
314
391
|
if (!mapped)
|
|
315
392
|
continue; // unknown-mapped-field already reported above
|
|
316
|
-
const
|
|
393
|
+
const columnId = eq.variableColumnIds?.[variable];
|
|
394
|
+
const boundName = describeBinding(mapped, columnId);
|
|
395
|
+
const dim = bindingDimension(mapped, columnId);
|
|
317
396
|
if (dim === 'none') {
|
|
318
|
-
push('variable-unit-on-dimensionless-field', unitPath, `Variable ${variable} maps to
|
|
397
|
+
push('variable-unit-on-dimensionless-field', unitPath, `Variable ${variable} maps to ${boundName}, which has no unit dimension`);
|
|
319
398
|
}
|
|
320
399
|
else if (unitDimension(unit) !== dim) {
|
|
321
|
-
push('variable-unit-dimension-mismatch', unitPath, `Unit "${unit}" is not a ${dim} unit (variable ${variable} maps to
|
|
400
|
+
push('variable-unit-dimension-mismatch', unitPath, `Unit "${unit}" is not a ${dim} unit (variable ${variable} maps to ${boundName})`);
|
|
322
401
|
}
|
|
323
402
|
}
|
|
324
403
|
// The unit the EXPRESSION produces, which is what `resultUnit` claims to
|
|
325
404
|
// be. Hoisted out of the `target` branch because the number-target check
|
|
326
405
|
// further down needs it too.
|
|
327
|
-
|
|
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);
|
|
328
414
|
if (target) {
|
|
329
415
|
const dim = fieldDimension(target);
|
|
330
416
|
if (eq.resultUnit != null) {
|
|
@@ -349,6 +435,11 @@ export const validateCalculatorDefinition = (input) => {
|
|
|
349
435
|
if (inferred.dimension !== dim) {
|
|
350
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`);
|
|
351
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
|
+
}
|
|
352
443
|
else if (eq.resultUnit == null) {
|
|
353
444
|
if (!producesCanonicalValue(inferred)) {
|
|
354
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}"` : ''}.`);
|
|
@@ -366,8 +457,24 @@ export const validateCalculatorDefinition = (input) => {
|
|
|
366
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.`);
|
|
367
458
|
}
|
|
368
459
|
}
|
|
369
|
-
|
|
370
|
-
|
|
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
|
+
}
|
|
371
478
|
}
|
|
372
479
|
if (!inferred.ok && inferred.reason === 'inconsistent-terms') {
|
|
373
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');
|
|
@@ -390,14 +497,21 @@ export const validateCalculatorDefinition = (input) => {
|
|
|
390
497
|
// authoring where a µm-scale value in a unitless field is intended, and
|
|
391
498
|
// nothing gates on `ok`: mobile only console.warns in __DEV__, and the
|
|
392
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.
|
|
393
505
|
if (target?.kind === ColumnType.Number &&
|
|
506
|
+
!isEquationBase(eq.base) &&
|
|
394
507
|
(!inferred.ok || inferred.dimension !== 'none')) {
|
|
395
508
|
for (const [variable, fieldId] of Object.entries(eq.variableToFieldId)) {
|
|
396
509
|
const mapped = fieldById.get(fieldId);
|
|
397
|
-
|
|
510
|
+
const columnId = eq.variableColumnIds?.[variable];
|
|
511
|
+
if (!mapped || bindingDimension(mapped, columnId) === 'none')
|
|
398
512
|
continue;
|
|
399
513
|
if (eq.variableUnits?.[variable] == null) {
|
|
400
|
-
push('canonical-value-into-number-target', `${path}.variableToFieldId.${variable}`, `"${target.name}" is a number field, so it shows the raw result — but variable ${variable} (
|
|
514
|
+
push('canonical-value-into-number-target', `${path}.variableToFieldId.${variable}`, `"${target.name}" is a number field, so it shows the raw result — but variable ${variable} (${describeBinding(mapped, columnId)}) has no display unit, so it arrives as a canonical µm-scale value and the result will be off by a large factor. Pick a unit for ${variable}.`);
|
|
401
515
|
}
|
|
402
516
|
}
|
|
403
517
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AnnotationCanvasState } from './annotation.js';
|
|
2
|
+
import type { CalculatorUnit } from '../calculator/units.js';
|
|
2
3
|
interface FirestoreDoc {
|
|
3
4
|
id: string;
|
|
4
5
|
}
|
|
@@ -277,9 +278,40 @@ export interface Formula extends FirestoreDoc, Timestamps {
|
|
|
277
278
|
variables: string[];
|
|
278
279
|
variableToColumnMap: Record<string, any>;
|
|
279
280
|
}
|
|
281
|
+
/**
|
|
282
|
+
* One VALUE column of a conversion table. A table with more than one column
|
|
283
|
+
* lets a single row selection carry several numbers at once ("2x4" -> actual
|
|
284
|
+
* width 1.5 in AND actual depth 3.5 in), each addressable from an equation.
|
|
285
|
+
*
|
|
286
|
+
* `unit` is what the cell text is written in. Without it a cell is a plain
|
|
287
|
+
* scalar (the pre-multi-column meaning of `Conversion.value`); with it, the
|
|
288
|
+
* cell is converted to the canonical value for that unit's dimension before
|
|
289
|
+
* it reaches an equation, exactly like a measurement field — which is what
|
|
290
|
+
* lets `width * depth` on a conversion table produce a real area instead of a
|
|
291
|
+
* dimensionless product. See calculator/conversionTable.ts.
|
|
292
|
+
*/
|
|
293
|
+
export interface ConversionColumn {
|
|
294
|
+
/** Stable; referenced by CalculatorEquation.variableColumnIds. */
|
|
295
|
+
id: string;
|
|
296
|
+
/** Author-facing; may be blank, in which case UIs fall back to `[i]`. */
|
|
297
|
+
name: string;
|
|
298
|
+
unit?: CalculatorUnit;
|
|
299
|
+
}
|
|
280
300
|
export interface Conversion {
|
|
281
301
|
label: string;
|
|
302
|
+
/**
|
|
303
|
+
* The FIRST column's value. Not merely a legacy alias: it is where column 0
|
|
304
|
+
* lives, full stop. Every reader that predates multi-column tables (job
|
|
305
|
+
* grids, label tiles, template formulas) reads this key, so column 0 stays
|
|
306
|
+
* here rather than moving into `values` and going dark for them.
|
|
307
|
+
*/
|
|
282
308
|
value: string;
|
|
309
|
+
/**
|
|
310
|
+
* Values for columns 1..n, keyed by `ConversionColumn.id`. Absent for a
|
|
311
|
+
* single-column table. Read through `conversionCellValue` rather than
|
|
312
|
+
* indexing this directly — it owns the column-0-lives-in-`value` rule.
|
|
313
|
+
*/
|
|
314
|
+
values?: Record<string, string>;
|
|
283
315
|
}
|
|
284
316
|
export interface FormulaColumnData {
|
|
285
317
|
formulaId: string;
|
|
@@ -290,6 +322,13 @@ export interface SelectColumnData {
|
|
|
290
322
|
}
|
|
291
323
|
export interface ConversionTableColumnData {
|
|
292
324
|
conversions: Conversion[];
|
|
325
|
+
/**
|
|
326
|
+
* Value columns in display order. Absent means the single implicit column
|
|
327
|
+
* backed by `Conversion.value` — the shape every table written before
|
|
328
|
+
* multi-column support has, and still the shape of a one-column table
|
|
329
|
+
* authored today. `conversionColumns()` normalizes both.
|
|
330
|
+
*/
|
|
331
|
+
columns?: ConversionColumn[];
|
|
293
332
|
}
|
|
294
333
|
export interface InstructionsColumnData {
|
|
295
334
|
text: string;
|
|
@@ -445,8 +484,8 @@ export declare enum Units {
|
|
|
445
484
|
FeetInchesDecimal = "ft_in_decimal",
|
|
446
485
|
FeetInchesFractional = "ft_in_frac"
|
|
447
486
|
}
|
|
448
|
-
export declare const convertUnitsToReadable: (targetUnit: Units) => "
|
|
449
|
-
export declare const convertUnitsToReadableShort: (targetUnit: Units) => "
|
|
487
|
+
export declare const convertUnitsToReadable: (targetUnit: Units) => "in" | "ft" | "m" | "mm" | "cm" | "in (fractional)" | "ft-in (decimal)" | "ft-in (fractional)" | null;
|
|
488
|
+
export declare const convertUnitsToReadableShort: (targetUnit: Units) => "in" | "ft" | "m" | "mm" | "cm" | "ft-in";
|
|
450
489
|
export declare enum FractionalTolerance {
|
|
451
490
|
Fourth = "4",
|
|
452
491
|
Eighth = "8",
|