@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.
@@ -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
@@ -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 { CALCULATOR_UNIT_INFO, unitDimension, } from './units.js';
5
- import { MATHJS_CONSTANTS, inferExpressionUnit, isEquivalentUnit, parseExpression, producesCanonicalValue, unitScaleRatio, } from './expressionUnits.js';
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({ label: z.string(), value: z.string() })),
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
- if (!Number.isFinite(parseFloat(c.value))) {
264
- push('conversion-value-not-numeric', `${path}.columnData.conversions.${j}.value`, `Conversion "${c.label}" has non-numeric value "${c.value}"`);
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 dim = fieldDimension(mapped);
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 "${mapped.name}", which has no unit dimension`);
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 "${mapped.name}")`);
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
- const inferred = inferExpressionUnit(def.fields, eq);
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
- if (inferred.ok && inferred.bases.length > 1) {
370
- push('mixed-base-units', `${path}.variableUnits`, `Variables mix ${inferred.bases.join(' and ')} in one expression, so the result has no single unit. Put them all in the same unit.`, 'warning');
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
- if (!mapped || fieldDimension(mapped) === 'none')
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} ("${mapped.name}") 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}.`);
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) => "cm" | "mm" | "m" | "in" | "ft" | "in (fractional)" | "ft-in (decimal)" | "ft-in (fractional)" | null;
449
- export declare const convertUnitsToReadableShort: (targetUnit: Units) => "cm" | "mm" | "m" | "in" | "ft" | "ft-in";
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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reekon-tools/boldr-utils",
3
- "version": "1.9.6",
3
+ "version": "1.10.1",
4
4
  "description": "Shared utilities for formulas and measurement conversion used in Reekon apps",
5
5
  "author": "REEKON Tools",
6
6
  "license": "MIT",