@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.
- 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 +18 -11
- package/dist/calculator/expressionUnits.d.ts +8 -7
- package/dist/calculator/expressionUnits.js +48 -15
- package/dist/calculator/index.d.ts +1 -0
- package/dist/calculator/index.js +1 -0
- package/dist/calculator/schema.d.ts +42 -0
- package/dist/calculator/schema.js +36 -0
- package/dist/calculator/validate.d.ts +7 -0
- package/dist/calculator/validate.js +88 -9
- package/dist/types/firestore.d.ts +41 -2
- package/package.json +1 -1
|
@@ -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
|
-
//
|
|
91
|
-
//
|
|
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
|
|
96
|
-
if (!
|
|
101
|
+
const row = findConversionRow(field.columnData, label);
|
|
102
|
+
if (!row)
|
|
97
103
|
return null;
|
|
98
|
-
|
|
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
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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 {
|
|
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 =
|
|
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
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
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.
|
|
385
|
-
|
|
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(
|
|
389
|
-
const field = fieldById.get(
|
|
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
|
-
|
|
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 =
|
|
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
|
-
//
|
|
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') {
|
package/dist/calculator/index.js
CHANGED
|
@@ -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({
|
|
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
|
-
|
|
264
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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} (
|
|
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) => "
|
|
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",
|