@reekon-tools/boldr-utils 1.9.6 → 1.10.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.
@@ -0,0 +1,81 @@
1
+ import type { Conversion, ConversionColumn, ConversionTableColumnData } from '../types/firestore.js';
2
+ import { type FieldDimension } from './units.js';
3
+ /**
4
+ * Id of the implicit first column. Stable and reserved: a table that has never
5
+ * been given explicit columns is addressed by this id, so equations written
6
+ * against a single-column table keep resolving after columns are added (the
7
+ * editor preserves it as `columns[0].id`).
8
+ */
9
+ export declare const DEFAULT_CONVERSION_COLUMN_ID = "value";
10
+ /** Name shown for the implicit first column. */
11
+ export declare const DEFAULT_CONVERSION_COLUMN_NAME = "Value";
12
+ /**
13
+ * A table's value columns, always at least one. An absent or empty `columns`
14
+ * normalizes to the single implicit column, so callers never branch on shape.
15
+ */
16
+ export declare const conversionColumns: (data: Pick<ConversionTableColumnData, "columns">) => ConversionColumn[];
17
+ /** True when equations must name a column to be unambiguous. */
18
+ export declare const hasMultipleConversionColumns: (data: Pick<ConversionTableColumnData, "columns">) => boolean;
19
+ /**
20
+ * The column an equation variable binds to. An absent/unknown `columnId` is the
21
+ * first column: that is what a binding written before multi-column tables
22
+ * meant, and the only reading that keeps such equations computing.
23
+ */
24
+ export declare const resolveConversionColumn: (data: Pick<ConversionTableColumnData, "columns">, columnId?: string) => ConversionColumn;
25
+ export declare const findConversionColumn: (data: Pick<ConversionTableColumnData, "columns">, columnId: string) => ConversionColumn | undefined;
26
+ /** Raw cell text for one row/column, or '' when the cell was never filled. */
27
+ export declare const conversionCellValue: (data: Pick<ConversionTableColumnData, "columns">, conversion: Conversion, columnId: string) => string;
28
+ /**
29
+ * `conversion` with one cell replaced, honouring the column-0-in-`value` rule.
30
+ * Returns a new object; `values` is omitted entirely when it would be empty so
31
+ * Firestore never receives a stray `{}`.
32
+ */
33
+ export declare const writeConversionCell: (data: Pick<ConversionTableColumnData, "columns">, conversion: Conversion, columnId: string, cell: string) => Conversion;
34
+ /** The dimension a column's values carry — 'none' for an unannotated column. */
35
+ export declare const conversionColumnDimension: (column: ConversionColumn) => FieldDimension;
36
+ /**
37
+ * The CANONICAL number a cell contributes to an equation, or null when the
38
+ * cell is blank or unparseable. Unit-less columns pass their value through
39
+ * unchanged (the historical meaning of a conversion factor); a column with a
40
+ * unit converts, so downstream code can treat it like any measurement value.
41
+ */
42
+ export declare const conversionCellNumber: (data: Pick<ConversionTableColumnData, "columns">, conversion: Conversion, columnId?: string) => number | null;
43
+ /** The row a selected label refers to. */
44
+ export declare const findConversionRow: (data: Pick<ConversionTableColumnData, "conversions">, label: string) => Conversion | undefined;
45
+ /**
46
+ * How a column is named in a chip, a picker or a validation message. Falls back
47
+ * to the positional `[i]` form the column is otherwise anonymous — an unnamed
48
+ * column still has to be tellable apart from its neighbours.
49
+ */
50
+ export declare const conversionColumnLabel: (data: Pick<ConversionTableColumnData, "columns">, columnId?: string) => string;
51
+ /**
52
+ * Qualified name for a binding — what the formula builder puts on a chip and
53
+ * what validation quotes back. Single-column tables read as the bare field
54
+ * name, so nothing changes for the tables that already exist.
55
+ */
56
+ export declare const conversionBindingLabel: (fieldName: string, data: Pick<ConversionTableColumnData, "columns">, columnId?: string) => string;
57
+ export interface ConversionRowDraft {
58
+ label: string;
59
+ /** Cell text per column id. Missing entries are treated as blank. */
60
+ cells: Record<string, string>;
61
+ }
62
+ /**
63
+ * Serialize an editor's (columns, rows) working state into storage shape.
64
+ * The single place the column-0 split is applied on write, and the reason the
65
+ * editor can add, rename, unit-annotate, reorder and delete columns without
66
+ * ever reasoning about which key a cell belongs in.
67
+ *
68
+ * A table whose only column is the plain implicit one is written WITHOUT a
69
+ * `columns` key, so it stays byte-identical to what the pre-multi-column editor
70
+ * produced — no schema churn on every table that doesn't use the feature. A
71
+ * single column that has been named or given a unit IS persisted: dropping it
72
+ * would silently discard the annotation.
73
+ */
74
+ export declare const buildConversionTableColumnData: (columns: ConversionColumn[], rows: ConversionRowDraft[]) => ConversionTableColumnData;
75
+ /** Storage shape back to editor working state. */
76
+ export declare const toConversionRowDrafts: (data: ConversionTableColumnData) => ConversionRowDraft[];
77
+ /** Convenience for pickers: every column's cell text for one row. */
78
+ export declare const conversionRowCells: (data: ConversionTableColumnData, conversion: Conversion) => {
79
+ column: ConversionColumn;
80
+ value: string;
81
+ }[];
@@ -0,0 +1,170 @@
1
+ import { toCanonical, unitDimension, } from './units.js';
2
+ // ---------------------------------------------------------------------------
3
+ // Multi-column conversion tables.
4
+ //
5
+ // A conversion table is a labelled lookup: the operator picks a ROW ("2x4",
6
+ // "30-year architectural") and the calculator gets a number. With more than one
7
+ // VALUE COLUMN, one selection carries several numbers — nominal vs. actual
8
+ // dimensions, weight and price per unit, rise and run — and an equation binds
9
+ // to a specific (field, column) pair rather than just the field.
10
+ //
11
+ // STORAGE (see types/firestore.ts): column 0's cell text lives in
12
+ // `Conversion.value`, columns 1..n in `Conversion.values[columnId]`. That split
13
+ // is not tidiness — `value` is the only key readers that predate this feature
14
+ // know about (job grids, label tiles, template formulas), so keeping column 0
15
+ // there means a table authored in a calculator still reads correctly
16
+ // everywhere else. Nothing outside this module should apply the rule by hand:
17
+ // go through `conversionCellValue` / `writeConversionCell`.
18
+ //
19
+ // UNITS: a column may declare the unit its cell text is written in. Cells then
20
+ // convert to canonical (µm/µm²/µm³/deg) on the way into an equation, so a
21
+ // column of inch values composes with measurement fields and with the unit
22
+ // inference in expressionUnits.ts. A column with no unit stays a plain scalar,
23
+ // which is exactly what a single-column table has always been.
24
+ // ---------------------------------------------------------------------------
25
+ /**
26
+ * Id of the implicit first column. Stable and reserved: a table that has never
27
+ * been given explicit columns is addressed by this id, so equations written
28
+ * against a single-column table keep resolving after columns are added (the
29
+ * editor preserves it as `columns[0].id`).
30
+ */
31
+ export const DEFAULT_CONVERSION_COLUMN_ID = 'value';
32
+ /** Name shown for the implicit first column. */
33
+ export const DEFAULT_CONVERSION_COLUMN_NAME = 'Value';
34
+ const IMPLICIT_COLUMN = {
35
+ id: DEFAULT_CONVERSION_COLUMN_ID,
36
+ name: DEFAULT_CONVERSION_COLUMN_NAME,
37
+ };
38
+ /**
39
+ * A table's value columns, always at least one. An absent or empty `columns`
40
+ * normalizes to the single implicit column, so callers never branch on shape.
41
+ */
42
+ export const conversionColumns = (data) => data.columns && data.columns.length > 0 ? data.columns : [IMPLICIT_COLUMN];
43
+ /** True when equations must name a column to be unambiguous. */
44
+ export const hasMultipleConversionColumns = (data) => conversionColumns(data).length > 1;
45
+ /**
46
+ * The column an equation variable binds to. An absent/unknown `columnId` is the
47
+ * first column: that is what a binding written before multi-column tables
48
+ * meant, and the only reading that keeps such equations computing.
49
+ */
50
+ export const resolveConversionColumn = (data, columnId) => {
51
+ const columns = conversionColumns(data);
52
+ if (columnId == null)
53
+ return columns[0];
54
+ return columns.find((c) => c.id === columnId) ?? columns[0];
55
+ };
56
+ export const findConversionColumn = (data, columnId) => conversionColumns(data).find((c) => c.id === columnId);
57
+ /** Raw cell text for one row/column, or '' when the cell was never filled. */
58
+ export const conversionCellValue = (data, conversion, columnId) => {
59
+ const columns = conversionColumns(data);
60
+ return columnId === columns[0].id
61
+ ? conversion.value
62
+ : (conversion.values?.[columnId] ?? '');
63
+ };
64
+ /**
65
+ * `conversion` with one cell replaced, honouring the column-0-in-`value` rule.
66
+ * Returns a new object; `values` is omitted entirely when it would be empty so
67
+ * Firestore never receives a stray `{}`.
68
+ */
69
+ export const writeConversionCell = (data, conversion, columnId, cell) => {
70
+ const columns = conversionColumns(data);
71
+ if (columnId === columns[0].id)
72
+ return { ...conversion, value: cell };
73
+ const values = { ...conversion.values, [columnId]: cell };
74
+ return { ...conversion, values };
75
+ };
76
+ /** The dimension a column's values carry — 'none' for an unannotated column. */
77
+ export const conversionColumnDimension = (column) => column.unit != null ? unitDimension(column.unit) : 'none';
78
+ /**
79
+ * The CANONICAL number a cell contributes to an equation, or null when the
80
+ * cell is blank or unparseable. Unit-less columns pass their value through
81
+ * unchanged (the historical meaning of a conversion factor); a column with a
82
+ * unit converts, so downstream code can treat it like any measurement value.
83
+ */
84
+ export const conversionCellNumber = (data, conversion, columnId) => {
85
+ const column = resolveConversionColumn(data, columnId);
86
+ const parsed = parseFloat(conversionCellValue(data, conversion, column.id));
87
+ if (!Number.isFinite(parsed))
88
+ return null;
89
+ return column.unit != null ? toCanonical(parsed, column.unit) : parsed;
90
+ };
91
+ /** The row a selected label refers to. */
92
+ export const findConversionRow = (data, label) => data.conversions.find((c) => c.label === label);
93
+ /**
94
+ * How a column is named in a chip, a picker or a validation message. Falls back
95
+ * to the positional `[i]` form the column is otherwise anonymous — an unnamed
96
+ * column still has to be tellable apart from its neighbours.
97
+ */
98
+ export const conversionColumnLabel = (data, columnId) => {
99
+ const columns = conversionColumns(data);
100
+ // An unknown id resolves to the first column, matching resolveConversionColumn
101
+ // — the label must name the column the equation will actually read.
102
+ const found = columnId == null ? -1 : columns.findIndex((c) => c.id === columnId);
103
+ const index = found < 0 ? 0 : found;
104
+ return columns[index].name.trim() !== '' ? columns[index].name : `[${index}]`;
105
+ };
106
+ /**
107
+ * Qualified name for a binding — what the formula builder puts on a chip and
108
+ * what validation quotes back. Single-column tables read as the bare field
109
+ * name, so nothing changes for the tables that already exist.
110
+ */
111
+ export const conversionBindingLabel = (fieldName, data, columnId) => hasMultipleConversionColumns(data)
112
+ ? `${fieldName} · ${conversionColumnLabel(data, columnId)}`
113
+ : fieldName;
114
+ /**
115
+ * True when a column carries nothing the implicit column doesn't already imply,
116
+ * so persisting it would only add a key. A named or unit-annotated column is
117
+ * NOT implicit even when it's the only one — that annotation is the difference
118
+ * between a table of inch values and a table of bare factors.
119
+ */
120
+ const isImplicitColumn = (column) => column.id === DEFAULT_CONVERSION_COLUMN_ID &&
121
+ column.unit == null &&
122
+ (column.name.trim() === '' || column.name === DEFAULT_CONVERSION_COLUMN_NAME);
123
+ /**
124
+ * Serialize an editor's (columns, rows) working state into storage shape.
125
+ * The single place the column-0 split is applied on write, and the reason the
126
+ * editor can add, rename, unit-annotate, reorder and delete columns without
127
+ * ever reasoning about which key a cell belongs in.
128
+ *
129
+ * A table whose only column is the plain implicit one is written WITHOUT a
130
+ * `columns` key, so it stays byte-identical to what the pre-multi-column editor
131
+ * produced — no schema churn on every table that doesn't use the feature. A
132
+ * single column that has been named or given a unit IS persisted: dropping it
133
+ * would silently discard the annotation.
134
+ */
135
+ export const buildConversionTableColumnData = (columns, rows) => {
136
+ const effective = columns.length > 0 ? columns : [IMPLICIT_COLUMN];
137
+ const [first, ...rest] = effective;
138
+ const conversions = rows.map((row) => {
139
+ const values = {};
140
+ for (const column of rest) {
141
+ const cell = row.cells[column.id];
142
+ if (cell != null && cell !== '')
143
+ values[column.id] = cell;
144
+ }
145
+ return {
146
+ label: row.label,
147
+ value: row.cells[first.id] ?? '',
148
+ ...(Object.keys(values).length > 0 ? { values } : {}),
149
+ };
150
+ });
151
+ return effective.length === 1 && isImplicitColumn(effective[0])
152
+ ? { conversions }
153
+ : { conversions, columns: effective };
154
+ };
155
+ /** Storage shape back to editor working state. */
156
+ export const toConversionRowDrafts = (data) => {
157
+ const columns = conversionColumns(data);
158
+ return data.conversions.map((conversion) => ({
159
+ label: conversion.label,
160
+ cells: Object.fromEntries(columns.map((column) => [
161
+ column.id,
162
+ conversionCellValue(data, conversion, column.id),
163
+ ])),
164
+ }));
165
+ };
166
+ /** Convenience for pickers: every column's cell text for one row. */
167
+ export const conversionRowCells = (data, conversion) => conversionColumns(data).map((column) => ({
168
+ column,
169
+ value: conversionCellValue(data, conversion, column.id),
170
+ }));
@@ -16,8 +16,12 @@ export declare const evaluateExpression: (expression: string, scope: Record<stri
16
16
  * The canonical numeric value a field contributes to an equation scope, or
17
17
  * null when the field has no usable value. Select and instructions fields are
18
18
  * non-computational (validation rejects mapping them into equations).
19
+ *
20
+ * `columnId` selects which value column a conversion-table field yields; it is
21
+ * ignored for every other kind. Omitted means the first column, so a caller
22
+ * that predates multi-column tables reads exactly what it used to.
19
23
  */
20
- export declare const numericFieldValue: (field: CalculatorField, value: CalculatorFieldValue | undefined) => number | null;
24
+ export declare const numericFieldValue: (field: CalculatorField, value: CalculatorFieldValue | undefined, columnId?: string) => number | null;
21
25
  export type EquationResult = {
22
26
  ok: true;
23
27
  value: number;
@@ -1,6 +1,7 @@
1
1
  import { create, all } from 'mathjs';
2
2
  import { ColumnType } from '../types/firestore.js';
3
3
  import { equationForField, findField } from './schema.js';
4
+ import { conversionCellNumber, findConversionRow } from './conversionTable.js';
4
5
  import { fromCanonical, toCanonical } from './units.js';
5
6
  // Trig in calculator equations works in DEGREES: angle fields are stored in
6
7
  // degrees canonically, and construction authors write `H = W * tan(A)`
@@ -77,8 +78,12 @@ export const evaluateExpression = (expression, scope) => {
77
78
  * The canonical numeric value a field contributes to an equation scope, or
78
79
  * null when the field has no usable value. Select and instructions fields are
79
80
  * non-computational (validation rejects mapping them into equations).
81
+ *
82
+ * `columnId` selects which value column a conversion-table field yields; it is
83
+ * ignored for every other kind. Omitted means the first column, so a caller
84
+ * that predates multi-column tables reads exactly what it used to.
80
85
  */
81
- export const numericFieldValue = (field, value) => {
86
+ export const numericFieldValue = (field, value, columnId) => {
82
87
  switch (field.kind) {
83
88
  case ColumnType.Number:
84
89
  case ColumnType.Measurement:
@@ -87,16 +92,16 @@ export const numericFieldValue = (field, value) => {
87
92
  return typeof v === 'number' && Number.isFinite(v) ? v : null;
88
93
  }
89
94
  case ColumnType.ConversionTable: {
90
- // Value is the selected conversion's label; the numeric factor comes
91
- // from the matched entry (Conversion.value is a string app-wide).
95
+ // The stored value is the selected ROW's label; the number comes from
96
+ // that row's cell in the requested column (cells are strings app-wide,
97
+ // and a unit-annotated column converts to canonical on the way out).
92
98
  const label = value ?? field.defaultValue;
93
99
  if (typeof label !== 'string')
94
100
  return null;
95
- const entry = field.columnData.conversions.find((c) => c.label === label);
96
- if (!entry)
101
+ const row = findConversionRow(field.columnData, label);
102
+ if (!row)
97
103
  return null;
98
- const parsed = parseFloat(entry.value);
99
- return Number.isFinite(parsed) ? parsed : null;
104
+ return conversionCellNumber(field.columnData, row, columnId);
100
105
  }
101
106
  default:
102
107
  return null;
@@ -118,7 +123,7 @@ const evaluateEquationInContext = (equation, ctx) => {
118
123
  const scope = {};
119
124
  const missing = [];
120
125
  for (const [variable, fieldId] of Object.entries(equation.variableToFieldId)) {
121
- const resolved = resolveFieldNumeric(fieldId, ctx);
126
+ const resolved = resolveFieldNumeric(fieldId, ctx, equation.variableColumnIds?.[variable]);
122
127
  if (!resolved.ok) {
123
128
  if (resolved.reason === 'missing-inputs') {
124
129
  missing.push(...resolved.missingFieldIds);
@@ -185,7 +190,9 @@ const evaluateEquationInContext = (equation, ctx) => {
185
190
  // computation is blocked on missing inputs but the user supplied a value for
186
191
  // the output directly (the two-way-solving posture), the supplied value is
187
192
  // used instead. Everything else resolves from `values`/defaults.
188
- const resolveFieldNumeric = (fieldId, ctx) => {
193
+ const resolveFieldNumeric = (fieldId, ctx,
194
+ /** Conversion-table column the referencing variable binds to. */
195
+ columnId) => {
189
196
  const field = findField(ctx.definition, fieldId);
190
197
  if (!field) {
191
198
  return {
@@ -200,13 +207,13 @@ const resolveFieldNumeric = (fieldId, ctx) => {
200
207
  if (computed.ok)
201
208
  return computed;
202
209
  if (computed.reason === 'missing-inputs') {
203
- const supplied = numericFieldValue(field, ctx.values[fieldId]);
210
+ const supplied = numericFieldValue(field, ctx.values[fieldId], columnId);
204
211
  if (supplied != null)
205
212
  return { ok: true, value: supplied };
206
213
  }
207
214
  return computed;
208
215
  }
209
- const value = numericFieldValue(field, ctx.values[fieldId]);
216
+ const value = numericFieldValue(field, ctx.values[fieldId], columnId);
210
217
  if (value == null) {
211
218
  return { ok: false, reason: 'missing-inputs', missingFieldIds: [fieldId] };
212
219
  }
@@ -50,7 +50,7 @@ export type ExpressionUnitInference = ({
50
50
  * The dimension and unit of an equation's raw expression value — what the
51
51
  * number means BEFORE `resultUnit` is applied.
52
52
  */
53
- export declare const inferExpressionUnit: (fields: readonly CalculatorField[], equation: Pick<CalculatorEquation, "expression" | "variableToFieldId" | "variableUnits">) => ExpressionUnitInference;
53
+ export declare const inferExpressionUnit: (fields: readonly CalculatorField[], equation: Pick<CalculatorEquation, "expression" | "variableToFieldId" | "variableColumnIds" | "variableUnits">) => ExpressionUnitInference;
54
54
  /**
55
55
  * True when an expression's raw value is already canonical, so omitting
56
56
  * `resultUnit` is correct rather than a 25400× mistake. Only the
@@ -70,13 +70,14 @@ export declare const unitScaleRatio: (declared: CalculatorUnit, actual: Calculat
70
70
  * (dimensionless or already-canonical result) or cannot be derived. An
71
71
  * existing equivalent unit is preserved so `in_frac` isn't churned to `in`.
72
72
  */
73
- export declare const deriveResultUnit: (fields: readonly CalculatorField[], equation: Pick<CalculatorEquation, "expression" | "variableToFieldId" | "variableUnits" | "resultUnit">) => CalculatorUnit | null;
73
+ export declare const deriveResultUnit: (fields: readonly CalculatorField[], equation: Pick<CalculatorEquation, "expression" | "variableToFieldId" | "variableColumnIds" | "variableUnits" | "resultUnit">) => CalculatorUnit | null;
74
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.
75
+ * Bring an equation's field-derived annotations variable column bindings,
76
+ * variable units, result unit back in line with the fields it references.
77
+ * Field edits (a dimension switch, a kind change, a deleted conversion column,
78
+ * a deleted field) are written independently of equations, so a stale entry
79
+ * would otherwise keep converting against a unit or reading a column the field
80
+ * no longer has — the failure mode this whole module exists to prevent.
80
81
  */
81
82
  export declare const reconcileEquationUnits: (fields: readonly CalculatorField[], equations: readonly CalculatorEquation[]) => {
82
83
  equations: CalculatorEquation[];
@@ -1,6 +1,7 @@
1
1
  import { create, all } from 'mathjs';
2
- import { Units } from '../types/firestore.js';
3
- import { fieldDefaultUnit, fieldDimension } from './schema.js';
2
+ import { ColumnType, Units } from '../types/firestore.js';
3
+ import { bindingDefaultUnit, bindingDimension, fieldDimension, } from './schema.js';
4
+ import { findConversionColumn } from './conversionTable.js';
4
5
  import { CALCULATOR_UNIT_INFO, toCanonical, unitDimension, } from './units.js';
5
6
  const math = create(all);
6
7
  // ---------------------------------------------------------------------------
@@ -143,7 +144,7 @@ export const inferExpressionUnit = (fields, equation) => {
143
144
  const field = fieldId != null ? fieldById.get(fieldId) : undefined;
144
145
  if (field) {
145
146
  // No variable unit: the value enters the scope canonically (units.ts).
146
- const dimension = fieldDimension(field);
147
+ const dimension = bindingDimension(field, equation.variableColumnIds?.[name]);
147
148
  const exponents = EXPONENTS_BY_DIMENSION[dimension];
148
149
  noteBases(exponents, dimension === 'angle' ? CANONICAL_ANGLE_BASE : CANONICAL_LENGTH_BASE);
149
150
  return exponents;
@@ -369,33 +370,65 @@ const withoutResultUnit = (equation) => {
369
370
  const { resultUnit: _dropped, ...rest } = equation;
370
371
  return rest;
371
372
  };
373
+ // Rebuild an equation without a key, since Firestore rejects `undefined`.
374
+ const withoutVariableColumnIds = (equation) => {
375
+ const { variableColumnIds: _dropped, ...rest } = equation;
376
+ return rest;
377
+ };
372
378
  /**
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.
379
+ * Bring an equation's field-derived annotations variable column bindings,
380
+ * variable units, result unit back in line with the fields it references.
381
+ * Field edits (a dimension switch, a kind change, a deleted conversion column,
382
+ * a deleted field) are written independently of equations, so a stale entry
383
+ * would otherwise keep converting against a unit or reading a column the field
384
+ * no longer has — the failure mode this whole module exists to prevent.
378
385
  */
379
386
  export const reconcileEquationUnits = (fields, equations) => {
380
387
  const fieldById = new Map(fields.map((f) => [f.id, f]));
381
388
  let changed = false;
382
389
  const next = equations.map((equation) => {
383
390
  let repaired = equation;
384
- // 1. Variable units must match the dimension of the field they annotate.
385
- if (equation.variableUnits) {
391
+ // 1. Column bindings must name a column the bound field still has. A
392
+ // dropped entry falls back to the first column, which is the same
393
+ // reading the evaluator gives it — so pruning here only removes a lie,
394
+ // it never changes what the equation computes.
395
+ if (equation.variableColumnIds) {
396
+ const variableColumnIds = {};
397
+ let bindingsChanged = false;
398
+ for (const [variable, columnId] of Object.entries(equation.variableColumnIds)) {
399
+ const field = fieldById.get(equation.variableToFieldId[variable] ?? '');
400
+ if (field?.kind === ColumnType.ConversionTable &&
401
+ findConversionColumn(field.columnData, columnId)) {
402
+ variableColumnIds[variable] = columnId;
403
+ continue;
404
+ }
405
+ bindingsChanged = true;
406
+ }
407
+ if (bindingsChanged) {
408
+ repaired =
409
+ Object.keys(variableColumnIds).length > 0
410
+ ? { ...repaired, variableColumnIds }
411
+ : withoutVariableColumnIds(repaired);
412
+ }
413
+ }
414
+ // 2. Variable units must match the dimension of the binding they annotate.
415
+ // Read against `repaired`, not `equation`: step 1 may have moved a
416
+ // variable back to the first column, whose dimension is what matters now.
417
+ if (repaired.variableUnits) {
386
418
  const variableUnits = {};
387
419
  let unitsChanged = false;
388
- for (const [variable, unit] of Object.entries(equation.variableUnits)) {
389
- const field = fieldById.get(equation.variableToFieldId[variable] ?? '');
420
+ for (const [variable, unit] of Object.entries(repaired.variableUnits)) {
421
+ const field = fieldById.get(repaired.variableToFieldId[variable] ?? '');
390
422
  if (!field) {
391
423
  unitsChanged = true; // variable or field is gone
392
424
  continue;
393
425
  }
394
- if (unitDimension(unit) === fieldDimension(field)) {
426
+ const columnId = repaired.variableColumnIds?.[variable];
427
+ if (unitDimension(unit) === bindingDimension(field, columnId)) {
395
428
  variableUnits[variable] = unit;
396
429
  continue;
397
430
  }
398
- const replacement = fieldDefaultUnit(field);
431
+ const replacement = bindingDefaultUnit(field, columnId);
399
432
  unitsChanged = true;
400
433
  if (replacement)
401
434
  variableUnits[variable] = replacement;
@@ -410,7 +443,7 @@ export const reconcileEquationUnits = (fields, equations) => {
410
443
  })();
411
444
  }
412
445
  }
413
- // 2. The result unit follows from the repaired variable units.
446
+ // 3. The result unit follows from the repaired variable units.
414
447
  const target = fieldById.get(equation.targetFieldId);
415
448
  const targetDimension = target ? fieldDimension(target) : 'none';
416
449
  if (targetDimension === 'none') {
@@ -1,6 +1,7 @@
1
1
  export * from './schema.js';
2
2
  export * from './categories.js';
3
3
  export * from './units.js';
4
+ export * from './conversionTable.js';
4
5
  export * from './expressionUnits.js';
5
6
  export * from './evaluate.js';
6
7
  export * from './solve.js';
@@ -5,6 +5,7 @@
5
5
  export * from './schema.js';
6
6
  export * from './categories.js';
7
7
  export * from './units.js';
8
+ export * from './conversionTable.js';
8
9
  export * from './expressionUnits.js';
9
10
  export * from './evaluate.js';
10
11
  export * from './solve.js';
@@ -111,6 +111,16 @@ export interface CalculatorEquation {
111
111
  expression: string;
112
112
  /** Variable letter -> field id. */
113
113
  variableToFieldId: Record<string, string>;
114
+ /**
115
+ * Variable letter -> `ConversionColumn.id`, for variables bound to a
116
+ * conversion-table field. Absent (or absent for one letter) means that
117
+ * variable reads the table's FIRST column — the only reading under which
118
+ * equations written before multi-column tables keep computing.
119
+ *
120
+ * Two letters may map to the same field with different columns: that is the
121
+ * whole point, one row selection feeding several terms of an expression.
122
+ */
123
+ variableColumnIds?: Record<string, string>;
114
124
  /**
115
125
  * Variable letter -> display unit its value enters the scope in. A mapped
116
126
  * variable without an entry stays canonical (µm-scale) — the pre-1.9
@@ -171,12 +181,44 @@ export declare const findEquation: (definition: Pick<CalculatorDefinition, "equa
171
181
  export declare const equationForField: (definition: Pick<CalculatorDefinition, "equations">, fieldId: string) => CalculatorEquation | undefined;
172
182
  /** The dimension a field's numeric value carries. */
173
183
  export declare const fieldDimension: (field: CalculatorField) => FieldDimension;
184
+ /**
185
+ * What one equation VARIABLE binds to: a field, and for a conversion table
186
+ * which of its value columns. Everything downstream of the variable map — unit
187
+ * inference, validation, the scope build — needs the pair, because a
188
+ * conversion table's dimension is a property of the column, not the field.
189
+ */
190
+ export interface VariableBinding {
191
+ fieldId: string;
192
+ /** Undefined for non-conversion fields and for first-column bindings. */
193
+ columnId?: string;
194
+ }
195
+ /** The binding for one variable letter, or null when the letter is unmapped. */
196
+ export declare const equationBinding: (equation: Pick<CalculatorEquation, "variableToFieldId" | "variableColumnIds">, variable: string) => VariableBinding | null;
197
+ /** Every variable's binding, in the variable map's order. */
198
+ export declare const equationBindings: (equation: Pick<CalculatorEquation, "variableToFieldId" | "variableColumnIds">) => (VariableBinding & {
199
+ variable: string;
200
+ })[];
201
+ /**
202
+ * The dimension a BOUND value carries. Same as `fieldDimension` for every kind
203
+ * but a conversion table, where a column may declare a unit and so give the
204
+ * binding a real dimension — the thing that lets `width · depth` off one table
205
+ * row infer as an area rather than a bare product.
206
+ */
207
+ export declare const bindingDimension: (field: CalculatorField, columnId?: string) => FieldDimension;
174
208
  /**
175
209
  * The unit a field's value is entered and displayed in, or null for fields
176
210
  * that carry no unit. Also the unit an equation referencing the field starts
177
211
  * out annotated with.
178
212
  */
179
213
  export declare const fieldDefaultUnit: (field: CalculatorField) => CalculatorUnit | null;
214
+ /**
215
+ * The unit a BOUND value is expressed in, or null when it carries none. For a
216
+ * conversion column this is the column's declared unit and is NOT a per-equation
217
+ * choice: the cell text was typed in that unit, so re-expressing the variable in
218
+ * another one would only invite the scale errors expressionUnits.ts exists to
219
+ * catch. The authoring UI shows it read-only for that reason.
220
+ */
221
+ export declare const bindingDefaultUnit: (field: CalculatorField, columnId?: string) => CalculatorUnit | null;
180
222
  /** Fields whose value participates in equations as a number. */
181
223
  export declare const isNumericFieldKind: (kind: CalculatorFieldKind) => kind is ColumnType.Number | ColumnType.Measurement | ColumnType.Angle | ColumnType.ConversionTable;
182
224
  /** Field kinds an equation may target (compute into). */
@@ -1,5 +1,6 @@
1
1
  import { ColumnType, Units, } from '../types/firestore.js';
2
2
  import { 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,16 @@ export const fieldDefaultUnit = (field) => {
74
100
  return null;
75
101
  }
76
102
  };
103
+ /**
104
+ * The unit a BOUND value is expressed in, or null when it carries none. For a
105
+ * conversion column this is the column's declared unit and is NOT a per-equation
106
+ * choice: the cell text was typed in that unit, so re-expressing the variable in
107
+ * another one would only invite the scale errors expressionUnits.ts exists to
108
+ * catch. The authoring UI shows it read-only for that reason.
109
+ */
110
+ export const bindingDefaultUnit = (field, columnId) => field.kind === ColumnType.ConversionTable
111
+ ? (resolveConversionColumn(field.columnData, columnId).unit ?? null)
112
+ : fieldDefaultUnit(field);
77
113
  /** Fields whose value participates in equations as a number. */
78
114
  export const isNumericFieldKind = (kind) => kind === ColumnType.Number ||
79
115
  kind === ColumnType.Measurement ||
@@ -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,7 @@ 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>>;
160
167
  variableUnits: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
161
168
  resultUnit: z.ZodOptional<z.ZodString>;
162
169
  }, z.core.$strip>>;
@@ -1,6 +1,7 @@
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';
3
+ import { CALCULATOR_SCHEMA_VERSION, bindingDimension, equationForField, fieldDimension, isEquationTargetKind, isNumericFieldKind, } from './schema.js';
4
+ import { conversionBindingLabel, conversionCellValue, conversionColumnLabel, conversionColumns, findConversionColumn, hasMultipleConversionColumns, } from './conversionTable.js';
4
5
  import { CALCULATOR_UNIT_INFO, unitDimension, } from './units.js';
5
6
  import { MATHJS_CONSTANTS, inferExpressionUnit, isEquivalentUnit, parseExpression, producesCanonicalValue, unitScaleRatio, } from './expressionUnits.js';
6
7
  import { isCalculatorCategoryId } from './categories.js';
@@ -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,7 @@ 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(),
96
109
  variableUnits: z.record(z.string(), calculatorUnitSchema).optional(),
97
110
  resultUnit: calculatorUnitSchema.optional(),
98
111
  });
@@ -163,6 +176,12 @@ const DIMENSION_LABELS = {
163
176
  none: 'a plain number',
164
177
  };
165
178
  const describeDimension = (dimension) => DIMENSION_LABELS[dimension];
179
+ // How a message names what a variable is bound to. A multi-column conversion
180
+ // table needs the column too — "maps to Lumber" is useless when the author has
181
+ // three columns and only one of them is wrong.
182
+ const describeBinding = (field, columnId) => field.kind === ColumnType.ConversionTable
183
+ ? `"${conversionBindingLabel(field.name, field.columnData, columnId)}"`
184
+ : `"${field.name}"`;
166
185
  // 1728 stays 1728; 57.29577951308232 becomes 57.3.
167
186
  const trimRatio = (ratio) => Number.isInteger(ratio) ? String(ratio) : ratio.toFixed(1);
168
187
  // Does `equation` depend on `fieldId`, directly or through the equations of
@@ -259,9 +278,37 @@ export const validateCalculatorDefinition = (input) => {
259
278
  if (conversions.length === 0) {
260
279
  push('empty-conversions', `${path}.columnData.conversions`, 'Conversion table is empty');
261
280
  }
281
+ // An explicit `columns` array is only ever written by the multi-column
282
+ // editor; a single-column table has none and normalizes to the implicit
283
+ // column, so these checks are no-ops for every table authored before now.
284
+ const columns = conversionColumns(field.columnData);
285
+ const columnIds = new Set();
286
+ columns.forEach((column, j) => {
287
+ if (columnIds.has(column.id)) {
288
+ push('duplicate-conversion-column-id', `${path}.columnData.columns.${j}.id`, `Duplicate conversion column id "${column.id}"`);
289
+ }
290
+ columnIds.add(column.id);
291
+ });
292
+ const columnNames = columns
293
+ .map((c) => c.name.trim())
294
+ .filter((n) => n !== '');
295
+ const dupeNames = columnNames.filter((n, j) => columnNames.indexOf(n) !== j);
296
+ if (dupeNames.length > 0) {
297
+ 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');
298
+ }
262
299
  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}"`);
300
+ for (const column of columns) {
301
+ const cell = conversionCellValue(field.columnData, c, column.id);
302
+ if (Number.isFinite(parseFloat(cell)))
303
+ continue;
304
+ // Path stays on `.value` for the implicit/first column so existing
305
+ // consumers of this issue path keep resolving to the same input.
306
+ const cellPath = column.id === columns[0].id
307
+ ? `${path}.columnData.conversions.${j}.value`
308
+ : `${path}.columnData.conversions.${j}.values.${column.id}`;
309
+ push('conversion-value-not-numeric', cellPath, columns.length > 1
310
+ ? `Conversion "${c.label}" has a non-numeric value "${cell}" in column "${conversionColumnLabel(field.columnData, column.id)}"`
311
+ : `Conversion "${c.label}" has non-numeric value "${cell}"`);
265
312
  }
266
313
  });
267
314
  const labels = conversions.map((c) => c.label);
@@ -298,10 +345,39 @@ export const validateCalculatorDefinition = (input) => {
298
345
  else if (!isNumericFieldKind(mapped.kind)) {
299
346
  push('non-numeric-mapped-field', `${path}.variableToFieldId.${variable}`, `Variable ${variable} maps to "${mapped.name}", which has no numeric value`);
300
347
  }
348
+ else if (mapped.kind === ColumnType.ConversionTable &&
349
+ hasMultipleConversionColumns(mapped.columnData) &&
350
+ eq.variableColumnIds?.[variable] == null) {
351
+ // Not an error: the evaluator reads the first column, which is what
352
+ // this binding meant before the table grew a second one. But it is
353
+ // very likely not what the author now wants, so say so.
354
+ 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');
355
+ }
301
356
  if (fieldId === eq.targetFieldId) {
302
357
  push('self-referencing-equation', `${path}.variableToFieldId.${variable}`, `Equation for "${eq.targetFieldId}" references its own target`);
303
358
  }
304
359
  }
360
+ // --- Column bindings ---------------------------------------------------
361
+ for (const [variable, columnId] of Object.entries(eq.variableColumnIds ?? {})) {
362
+ const columnPath = `${path}.variableColumnIds.${variable}`;
363
+ const mappedId = eq.variableToFieldId[variable];
364
+ if (mappedId == null) {
365
+ push('column-for-unmapped-variable', columnPath, `Variable ${variable} names a conversion column but has no field mapping`, 'warning');
366
+ continue;
367
+ }
368
+ const mapped = fieldById.get(mappedId);
369
+ if (!mapped)
370
+ continue; // unknown-mapped-field already reported above
371
+ if (mapped.kind !== ColumnType.ConversionTable) {
372
+ 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');
373
+ continue;
374
+ }
375
+ if (!findConversionColumn(mapped.columnData, columnId)) {
376
+ // The evaluator falls back to the first column, so the equation still
377
+ // computes — with a value the author never asked for. An error.
378
+ 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)}"`);
379
+ }
380
+ }
305
381
  // --- Unit annotations --------------------------------------------------
306
382
  for (const [variable, unit] of Object.entries(eq.variableUnits ?? {})) {
307
383
  const unitPath = `${path}.variableUnits.${variable}`;
@@ -313,12 +389,14 @@ export const validateCalculatorDefinition = (input) => {
313
389
  const mapped = fieldById.get(mappedId);
314
390
  if (!mapped)
315
391
  continue; // unknown-mapped-field already reported above
316
- const dim = fieldDimension(mapped);
392
+ const columnId = eq.variableColumnIds?.[variable];
393
+ const boundName = describeBinding(mapped, columnId);
394
+ const dim = bindingDimension(mapped, columnId);
317
395
  if (dim === 'none') {
318
- push('variable-unit-on-dimensionless-field', unitPath, `Variable ${variable} maps to "${mapped.name}", which has no unit dimension`);
396
+ push('variable-unit-on-dimensionless-field', unitPath, `Variable ${variable} maps to ${boundName}, which has no unit dimension`);
319
397
  }
320
398
  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}")`);
399
+ push('variable-unit-dimension-mismatch', unitPath, `Unit "${unit}" is not a ${dim} unit (variable ${variable} maps to ${boundName})`);
322
400
  }
323
401
  }
324
402
  // The unit the EXPRESSION produces, which is what `resultUnit` claims to
@@ -394,10 +472,11 @@ export const validateCalculatorDefinition = (input) => {
394
472
  (!inferred.ok || inferred.dimension !== 'none')) {
395
473
  for (const [variable, fieldId] of Object.entries(eq.variableToFieldId)) {
396
474
  const mapped = fieldById.get(fieldId);
397
- if (!mapped || fieldDimension(mapped) === 'none')
475
+ const columnId = eq.variableColumnIds?.[variable];
476
+ if (!mapped || bindingDimension(mapped, columnId) === 'none')
398
477
  continue;
399
478
  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}.`);
479
+ 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
480
  }
402
481
  }
403
482
  }
@@ -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.0",
4
4
  "description": "Shared utilities for formulas and measurement conversion used in Reekon apps",
5
5
  "author": "REEKON Tools",
6
6
  "license": "MIT",