@reekon-tools/boldr-utils 1.9.6 → 1.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/calculator/conversionTable.d.ts +81 -0
- package/dist/calculator/conversionTable.js +170 -0
- package/dist/calculator/evaluate.d.ts +5 -1
- package/dist/calculator/evaluate.js +67 -18
- package/dist/calculator/expressionUnits.d.ts +103 -8
- package/dist/calculator/expressionUnits.js +318 -63
- package/dist/calculator/index.d.ts +1 -0
- package/dist/calculator/index.js +1 -0
- package/dist/calculator/schema.d.ts +87 -4
- package/dist/calculator/schema.js +61 -1
- package/dist/calculator/units.d.ts +27 -0
- package/dist/calculator/units.js +47 -0
- package/dist/calculator/validate.d.ts +14 -0
- package/dist/calculator/validate.js +128 -14
- package/dist/types/firestore.d.ts +41 -2
- package/package.json +1 -1
|
@@ -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,7 +1,9 @@
|
|
|
1
1
|
import { create, all } from 'mathjs';
|
|
2
2
|
import { ColumnType } from '../types/firestore.js';
|
|
3
|
-
import { equationForField, findField } from './schema.js';
|
|
4
|
-
import {
|
|
3
|
+
import { bindingUnitForBase, equationForField, fieldDimension, findField, } from './schema.js';
|
|
4
|
+
import { conversionCellNumber, findConversionRow } from './conversionTable.js';
|
|
5
|
+
import { deriveResultUnitForBase } from './expressionUnits.js';
|
|
6
|
+
import { fromCanonical, isEquationBase, toCanonical, } from './units.js';
|
|
5
7
|
// Trig in calculator equations works in DEGREES: angle fields are stored in
|
|
6
8
|
// degrees canonically, and construction authors write `H = W * tan(A)`
|
|
7
9
|
// expecting A in degrees. mathjs has no `angle` config option (evaluateFormula
|
|
@@ -77,8 +79,12 @@ export const evaluateExpression = (expression, scope) => {
|
|
|
77
79
|
* The canonical numeric value a field contributes to an equation scope, or
|
|
78
80
|
* null when the field has no usable value. Select and instructions fields are
|
|
79
81
|
* non-computational (validation rejects mapping them into equations).
|
|
82
|
+
*
|
|
83
|
+
* `columnId` selects which value column a conversion-table field yields; it is
|
|
84
|
+
* ignored for every other kind. Omitted means the first column, so a caller
|
|
85
|
+
* that predates multi-column tables reads exactly what it used to.
|
|
80
86
|
*/
|
|
81
|
-
export const numericFieldValue = (field, value) => {
|
|
87
|
+
export const numericFieldValue = (field, value, columnId) => {
|
|
82
88
|
switch (field.kind) {
|
|
83
89
|
case ColumnType.Number:
|
|
84
90
|
case ColumnType.Measurement:
|
|
@@ -87,21 +93,61 @@ export const numericFieldValue = (field, value) => {
|
|
|
87
93
|
return typeof v === 'number' && Number.isFinite(v) ? v : null;
|
|
88
94
|
}
|
|
89
95
|
case ColumnType.ConversionTable: {
|
|
90
|
-
//
|
|
91
|
-
//
|
|
96
|
+
// The stored value is the selected ROW's label; the number comes from
|
|
97
|
+
// that row's cell in the requested column (cells are strings app-wide,
|
|
98
|
+
// and a unit-annotated column converts to canonical on the way out).
|
|
92
99
|
const label = value ?? field.defaultValue;
|
|
93
100
|
if (typeof label !== 'string')
|
|
94
101
|
return null;
|
|
95
|
-
const
|
|
96
|
-
if (!
|
|
102
|
+
const row = findConversionRow(field.columnData, label);
|
|
103
|
+
if (!row)
|
|
97
104
|
return null;
|
|
98
|
-
|
|
99
|
-
return Number.isFinite(parsed) ? parsed : null;
|
|
105
|
+
return conversionCellNumber(field.columnData, row, columnId);
|
|
100
106
|
}
|
|
101
107
|
default:
|
|
102
108
|
return null;
|
|
103
109
|
}
|
|
104
110
|
};
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// Scope units.
|
|
113
|
+
//
|
|
114
|
+
// A base-anchored equation derives every unit from its base rather than
|
|
115
|
+
// reading `variableUnits` / `resultUnit` off the document. Those fields are
|
|
116
|
+
// still written (older app builds evaluate from them), but they are a cache of
|
|
117
|
+
// this derivation — and trusting a cache over its source is exactly how a
|
|
118
|
+
// stale entry silently rescales a live answer. So the evaluator recomputes.
|
|
119
|
+
//
|
|
120
|
+
// Equations with no base keep the legacy reading, byte for byte: saved
|
|
121
|
+
// calculator instances freeze their definition, so pre-base equations must go
|
|
122
|
+
// on evaluating exactly as they did the day they were saved.
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
/** The unit one variable's canonical value is re-expressed in before scoping. */
|
|
125
|
+
const scopeUnitFor = (definition, equation, variable, fieldId) => {
|
|
126
|
+
if (!isEquationBase(equation.base)) {
|
|
127
|
+
return equation.variableUnits?.[variable] ?? null;
|
|
128
|
+
}
|
|
129
|
+
const field = findField(definition, fieldId);
|
|
130
|
+
if (!field)
|
|
131
|
+
return null;
|
|
132
|
+
return bindingUnitForBase(equation.base, field, equation.variableColumnIds?.[variable]);
|
|
133
|
+
};
|
|
134
|
+
/** The unit the expression's raw result carries, or null when it needs none. */
|
|
135
|
+
const resultUnitFor = (definition, equation) => {
|
|
136
|
+
if (!isEquationBase(equation.base))
|
|
137
|
+
return equation.resultUnit ?? null;
|
|
138
|
+
const target = findField(definition, equation.targetFieldId);
|
|
139
|
+
// A number target takes the raw value — the whole point of evaluating in
|
|
140
|
+
// display units is that `area_in_ft² / 33` yields squares, not µm².
|
|
141
|
+
if (!target || fieldDimension(target) === 'none')
|
|
142
|
+
return null;
|
|
143
|
+
const derived = deriveResultUnitForBase(equation.base, definition.fields, equation);
|
|
144
|
+
if (derived.kind === 'unit')
|
|
145
|
+
return derived.unit;
|
|
146
|
+
if (derived.kind === 'none')
|
|
147
|
+
return null;
|
|
148
|
+
// Inference declined; the author's stored unit is the only information left.
|
|
149
|
+
return equation.resultUnit ?? null;
|
|
150
|
+
};
|
|
105
151
|
const evaluateEquationInContext = (equation, ctx) => {
|
|
106
152
|
const cached = ctx.cache.get(equation.id);
|
|
107
153
|
if (cached)
|
|
@@ -118,7 +164,7 @@ const evaluateEquationInContext = (equation, ctx) => {
|
|
|
118
164
|
const scope = {};
|
|
119
165
|
const missing = [];
|
|
120
166
|
for (const [variable, fieldId] of Object.entries(equation.variableToFieldId)) {
|
|
121
|
-
const resolved = resolveFieldNumeric(fieldId, ctx);
|
|
167
|
+
const resolved = resolveFieldNumeric(fieldId, ctx, equation.variableColumnIds?.[variable]);
|
|
122
168
|
if (!resolved.ok) {
|
|
123
169
|
if (resolved.reason === 'missing-inputs') {
|
|
124
170
|
missing.push(...resolved.missingFieldIds);
|
|
@@ -126,9 +172,9 @@ const evaluateEquationInContext = (equation, ctx) => {
|
|
|
126
172
|
}
|
|
127
173
|
return resolved;
|
|
128
174
|
}
|
|
129
|
-
// Field values resolve canonically;
|
|
130
|
-
//
|
|
131
|
-
const unit = equation
|
|
175
|
+
// Field values resolve canonically; the scope unit re-expresses each one
|
|
176
|
+
// in the equation's own terms before it enters the expression.
|
|
177
|
+
const unit = scopeUnitFor(ctx.definition, equation, variable, fieldId);
|
|
132
178
|
try {
|
|
133
179
|
scope[variable] =
|
|
134
180
|
unit != null ? fromCanonical(resolved.value, unit) : resolved.value;
|
|
@@ -158,11 +204,12 @@ const evaluateEquationInContext = (equation, ctx) => {
|
|
|
158
204
|
else {
|
|
159
205
|
// A result unit means the expression produced a display-unit value;
|
|
160
206
|
// convert back so the returned value is canonical like everything else.
|
|
207
|
+
const resultUnit = resultUnitFor(ctx.definition, equation);
|
|
161
208
|
try {
|
|
162
209
|
result = {
|
|
163
210
|
ok: true,
|
|
164
|
-
value:
|
|
165
|
-
? toCanonical(evaluated.value,
|
|
211
|
+
value: resultUnit != null
|
|
212
|
+
? toCanonical(evaluated.value, resultUnit)
|
|
166
213
|
: evaluated.value,
|
|
167
214
|
};
|
|
168
215
|
}
|
|
@@ -185,7 +232,9 @@ const evaluateEquationInContext = (equation, ctx) => {
|
|
|
185
232
|
// computation is blocked on missing inputs but the user supplied a value for
|
|
186
233
|
// the output directly (the two-way-solving posture), the supplied value is
|
|
187
234
|
// used instead. Everything else resolves from `values`/defaults.
|
|
188
|
-
const resolveFieldNumeric = (fieldId, ctx
|
|
235
|
+
const resolveFieldNumeric = (fieldId, ctx,
|
|
236
|
+
/** Conversion-table column the referencing variable binds to. */
|
|
237
|
+
columnId) => {
|
|
189
238
|
const field = findField(ctx.definition, fieldId);
|
|
190
239
|
if (!field) {
|
|
191
240
|
return {
|
|
@@ -200,13 +249,13 @@ const resolveFieldNumeric = (fieldId, ctx) => {
|
|
|
200
249
|
if (computed.ok)
|
|
201
250
|
return computed;
|
|
202
251
|
if (computed.reason === 'missing-inputs') {
|
|
203
|
-
const supplied = numericFieldValue(field, ctx.values[fieldId]);
|
|
252
|
+
const supplied = numericFieldValue(field, ctx.values[fieldId], columnId);
|
|
204
253
|
if (supplied != null)
|
|
205
254
|
return { ok: true, value: supplied };
|
|
206
255
|
}
|
|
207
256
|
return computed;
|
|
208
257
|
}
|
|
209
|
-
const value = numericFieldValue(field, ctx.values[fieldId]);
|
|
258
|
+
const value = numericFieldValue(field, ctx.values[fieldId], columnId);
|
|
210
259
|
if (value == null) {
|
|
211
260
|
return { ok: false, reason: 'missing-inputs', missingFieldIds: [fieldId] };
|
|
212
261
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type MathNode } from 'mathjs';
|
|
2
2
|
import type { CalculatorEquation, CalculatorField } from './schema.js';
|
|
3
|
-
import { type CalculatorUnit, type FieldDimension } from './units.js';
|
|
3
|
+
import { type CalculatorUnit, type EquationBase, type FieldDimension } from './units.js';
|
|
4
4
|
/** Constants mathjs resolves without a scope entry. */
|
|
5
5
|
export declare const MATHJS_CONSTANTS: Set<string>;
|
|
6
6
|
export type ParseResult = {
|
|
@@ -39,6 +39,20 @@ export interface InferredExpressionUnit {
|
|
|
39
39
|
unit: CalculatorUnit | null;
|
|
40
40
|
/** Distinct base units among the variables carrying that dimension. */
|
|
41
41
|
bases: string[];
|
|
42
|
+
/**
|
|
43
|
+
* Every distinct length-family base the variables contributed, whether or
|
|
44
|
+
* not the exponents survived to the result.
|
|
45
|
+
*
|
|
46
|
+
* Separate from `bases` because `bases` describes the RESULT, and a result
|
|
47
|
+
* can be dimensionless while its inputs still disagree: `(A_in * B_in) /
|
|
48
|
+
* C_ft²` cancels to a plain count, so `bases` is empty and `dimension` is
|
|
49
|
+
* 'none' — and the 144× scale error that ratio carries is invisible there.
|
|
50
|
+
* Tracked per axis, since a length and an angle in one expression is normal
|
|
51
|
+
* (trig consumes the angle) while two length bases never is.
|
|
52
|
+
*/
|
|
53
|
+
lengthBases: string[];
|
|
54
|
+
/** Every distinct angle base the variables contributed ('deg' / 'rad'). */
|
|
55
|
+
angleBases: string[];
|
|
42
56
|
}
|
|
43
57
|
export type ExpressionUnitInference = ({
|
|
44
58
|
ok: true;
|
|
@@ -50,7 +64,7 @@ export type ExpressionUnitInference = ({
|
|
|
50
64
|
* The dimension and unit of an equation's raw expression value — what the
|
|
51
65
|
* number means BEFORE `resultUnit` is applied.
|
|
52
66
|
*/
|
|
53
|
-
export declare const inferExpressionUnit: (fields: readonly CalculatorField[], equation: Pick<CalculatorEquation, "expression" | "variableToFieldId" | "variableUnits">) => ExpressionUnitInference;
|
|
67
|
+
export declare const inferExpressionUnit: (fields: readonly CalculatorField[], equation: Pick<CalculatorEquation, "expression" | "variableToFieldId" | "variableColumnIds" | "variableUnits">) => ExpressionUnitInference;
|
|
54
68
|
/**
|
|
55
69
|
* True when an expression's raw value is already canonical, so omitting
|
|
56
70
|
* `resultUnit` is correct rather than a 25400× mistake. Only the
|
|
@@ -70,13 +84,94 @@ export declare const unitScaleRatio: (declared: CalculatorUnit, actual: Calculat
|
|
|
70
84
|
* (dimensionless or already-canonical result) or cannot be derived. An
|
|
71
85
|
* existing equivalent unit is preserved so `in_frac` isn't churned to `in`.
|
|
72
86
|
*/
|
|
73
|
-
export declare const deriveResultUnit: (fields: readonly CalculatorField[], equation: Pick<CalculatorEquation, "expression" | "variableToFieldId" | "variableUnits" | "resultUnit">) => CalculatorUnit | null;
|
|
87
|
+
export declare const deriveResultUnit: (fields: readonly CalculatorField[], equation: Pick<CalculatorEquation, "expression" | "variableToFieldId" | "variableColumnIds" | "variableUnits" | "resultUnit">) => CalculatorUnit | null;
|
|
88
|
+
/** The scope unit for every variable of a base-anchored equation. */
|
|
89
|
+
export declare const deriveVariableUnits: (base: EquationBase, fields: readonly CalculatorField[], equation: Pick<CalculatorEquation, "variableToFieldId" | "variableColumnIds">) => Record<string, CalculatorUnit>;
|
|
90
|
+
export type DerivedResultUnit =
|
|
91
|
+
/** The raw value carries this unit and must be converted back through it. */
|
|
92
|
+
{
|
|
93
|
+
kind: 'unit';
|
|
94
|
+
unit: CalculatorUnit;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Inference succeeded and the raw value needs NO conversion: dimensionless,
|
|
98
|
+
* or an angle (degrees are already canonical).
|
|
99
|
+
*/
|
|
100
|
+
| {
|
|
101
|
+
kind: 'none';
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Inference declined — an unmodelled function, a non-integer power, a
|
|
105
|
+
* dimension this vocabulary can't name. The base says nothing useful here,
|
|
106
|
+
* so callers fall back to whatever unit the equation already carried. This
|
|
107
|
+
* is the escape hatch the authoring UI's manual result-unit picker fills.
|
|
108
|
+
*/
|
|
109
|
+
| {
|
|
110
|
+
kind: 'unknown';
|
|
111
|
+
};
|
|
112
|
+
/** The unit a base-anchored expression's raw value carries. */
|
|
113
|
+
export declare const deriveResultUnitForBase: (base: EquationBase, fields: readonly CalculatorField[], equation: Pick<CalculatorEquation, "expression" | "variableToFieldId" | "variableColumnIds">) => DerivedResultUnit;
|
|
114
|
+
/** Where a migrated equation's base came from — reported by the migration. */
|
|
115
|
+
export type EquationBaseSource =
|
|
116
|
+
/** A bound conversion column's declared unit. */
|
|
117
|
+
'conversion-column'
|
|
118
|
+
/** An existing authored `variableUnits` entry. */
|
|
119
|
+
| 'variable-unit'
|
|
120
|
+
/** A bound measurement field's display unit. */
|
|
121
|
+
| 'field-default'
|
|
122
|
+
/** Nothing length-valued to anchor to; the base is inert. */
|
|
123
|
+
| 'fallback'
|
|
124
|
+
/** A variable is in a unit family no base can express — see `blockedBy`. */
|
|
125
|
+
| 'unanchorable';
|
|
126
|
+
export interface DerivedEquationBase {
|
|
127
|
+
/** Null when the equation cannot be anchored without changing its meaning. */
|
|
128
|
+
base: EquationBase | null;
|
|
129
|
+
source: EquationBaseSource;
|
|
130
|
+
/**
|
|
131
|
+
* Distinct bases seen at the winning tier. More than one means the source
|
|
132
|
+
* disagreed with itself and `base` is the most common of them — the case a
|
|
133
|
+
* migration should surface rather than apply silently.
|
|
134
|
+
*/
|
|
135
|
+
candidates: EquationBase[];
|
|
136
|
+
/**
|
|
137
|
+
* Units that blocked anchoring: cubic yards, liters, gallons — real units a
|
|
138
|
+
* field may hold, but outside the five families a base can name.
|
|
139
|
+
*/
|
|
140
|
+
blockedBy?: CalculatorUnit[];
|
|
141
|
+
}
|
|
142
|
+
/** Inert default when an equation has no length-valued binding at all. */
|
|
143
|
+
export declare const DEFAULT_EQUATION_BASE: EquationBase;
|
|
144
|
+
/**
|
|
145
|
+
* The base a pre-`base` equation should adopt, in descending order of what the
|
|
146
|
+
* source can be trusted to mean:
|
|
147
|
+
*
|
|
148
|
+
* 1. A bound conversion COLUMN's unit. A cell reading "5.33" is literal text
|
|
149
|
+
* a human typed; its unit is the only thing that says what the number is,
|
|
150
|
+
* so it is the one declaration in the equation that cannot be re-expressed
|
|
151
|
+
* without reinterpreting authored data.
|
|
152
|
+
* 2. An existing `variableUnits` entry — the author's own stated intent,
|
|
153
|
+
* even if it disagreed with its neighbours.
|
|
154
|
+
* 3. A bound measurement field's display unit. Values are stored canonically,
|
|
155
|
+
* so this only reflects a preference, but it is the author's preference.
|
|
156
|
+
* 4. Nothing length-valued in the equation, so the base cannot matter.
|
|
157
|
+
*
|
|
158
|
+
* Declines entirely (`base: null`) when a variable is ALREADY authored in a
|
|
159
|
+
* unit no base can name — cubic yards, liters, gallons. Re-expressing those is
|
|
160
|
+
* numerically lossless but semantically not: a gravel estimator multiplying a
|
|
161
|
+
* cu_yd volume by a tons-per-cubic-yard constant is correct only while the
|
|
162
|
+
* volume stays in cubic yards, and that constant is a bare number with nothing
|
|
163
|
+
* to declare its units. Silently rebasing it to cu_in would make the answer
|
|
164
|
+
* 46,656× too large. Better to leave such an equation on the pre-base path,
|
|
165
|
+
* where the mixed-base check still watches it, than to guess.
|
|
166
|
+
*/
|
|
167
|
+
export declare const deriveEquationBase: (fields: readonly CalculatorField[], equation: Pick<CalculatorEquation, "variableToFieldId" | "variableColumnIds" | "variableUnits">) => DerivedEquationBase;
|
|
74
168
|
/**
|
|
75
|
-
* Bring an equation's
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
169
|
+
* Bring an equation's field-derived annotations — variable column bindings,
|
|
170
|
+
* variable units, result unit — back in line with the fields it references.
|
|
171
|
+
* Field edits (a dimension switch, a kind change, a deleted conversion column,
|
|
172
|
+
* a deleted field) are written independently of equations, so a stale entry
|
|
173
|
+
* would otherwise keep converting against a unit or reading a column the field
|
|
174
|
+
* no longer has — the failure mode this whole module exists to prevent.
|
|
80
175
|
*/
|
|
81
176
|
export declare const reconcileEquationUnits: (fields: readonly CalculatorField[], equations: readonly CalculatorEquation[]) => {
|
|
82
177
|
equations: CalculatorEquation[];
|