@reekon-tools/boldr-utils 1.9.6 → 1.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/calculator/conversionTable.d.ts +81 -0
- package/dist/calculator/conversionTable.js +170 -0
- package/dist/calculator/evaluate.d.ts +5 -1
- package/dist/calculator/evaluate.js +67 -18
- package/dist/calculator/expressionUnits.d.ts +103 -8
- package/dist/calculator/expressionUnits.js +318 -63
- package/dist/calculator/index.d.ts +1 -0
- package/dist/calculator/index.js +1 -0
- package/dist/calculator/schema.d.ts +87 -4
- package/dist/calculator/schema.js +61 -1
- package/dist/calculator/units.d.ts +27 -0
- package/dist/calculator/units.js +47 -0
- package/dist/calculator/validate.d.ts +14 -0
- package/dist/calculator/validate.js +128 -14
- package/dist/types/firestore.d.ts +41 -2
- package/package.json +1 -1
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { create, all } from 'mathjs';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { ColumnType } from '../types/firestore.js';
|
|
3
|
+
import { bindingDefaultUnit, bindingDimension, bindingUnitForBase, equationBindings, fieldDimension, } from './schema.js';
|
|
4
|
+
import { findConversionColumn } from './conversionTable.js';
|
|
5
|
+
import { UNIT_BY_BASE, equationBaseOfUnit, isEquationBase, toCanonical, unitBase, unitDimension, unitForBase, } from './units.js';
|
|
5
6
|
const math = create(all);
|
|
6
7
|
// ---------------------------------------------------------------------------
|
|
7
8
|
// Expression unit inference.
|
|
@@ -43,23 +44,41 @@ export const MATHJS_CONSTANTS = new Set([
|
|
|
43
44
|
'NaN',
|
|
44
45
|
'null',
|
|
45
46
|
]);
|
|
47
|
+
// Parse cache, mirroring evaluate.ts's compile cache and sized to match. Unit
|
|
48
|
+
// inference now runs on the evaluation path (an equation's scope units are
|
|
49
|
+
// derived from its base, not read off the document), so the same handful of
|
|
50
|
+
// expressions get parsed on every keystroke of the editor preview.
|
|
51
|
+
//
|
|
52
|
+
// The cached node is handed to callers directly. Every consumer in this
|
|
53
|
+
// package only reads it — `traverse` in expressionSymbols, the structural walk
|
|
54
|
+
// below — so nothing mutates the shared tree.
|
|
55
|
+
const parseCache = new Map();
|
|
56
|
+
const PARSE_CACHE_MAX = 200;
|
|
46
57
|
/** Shared mathjs parse — the one place expression text becomes a tree. */
|
|
47
58
|
export const parseExpression = (expression) => {
|
|
59
|
+
const hit = parseCache.get(expression);
|
|
60
|
+
if (hit)
|
|
61
|
+
return hit;
|
|
62
|
+
let result;
|
|
48
63
|
try {
|
|
49
|
-
|
|
64
|
+
result = { ok: true, node: math.parse(expression) };
|
|
50
65
|
}
|
|
51
66
|
catch (err) {
|
|
52
|
-
|
|
67
|
+
result = {
|
|
53
68
|
ok: false,
|
|
54
69
|
error: err instanceof Error ? err.message : String(err),
|
|
55
70
|
};
|
|
56
71
|
}
|
|
72
|
+
if (parseCache.size >= PARSE_CACHE_MAX)
|
|
73
|
+
parseCache.clear();
|
|
74
|
+
parseCache.set(expression, result);
|
|
75
|
+
return result;
|
|
57
76
|
};
|
|
58
77
|
// The math.js base token a unit reduces to: 'sq_ft' -> 'ft', 'in_frac' -> 'in',
|
|
59
78
|
// 'liter' -> 'L'. Two units share a base exactly when they are the same
|
|
60
79
|
// physical unit at different exponents, which is what makes a result unit
|
|
61
80
|
// nameable.
|
|
62
|
-
const baseUnitOf =
|
|
81
|
+
const baseUnitOf = unitBase;
|
|
63
82
|
/**
|
|
64
83
|
* Bases whose values are ALREADY canonical, so an equation in them needs no
|
|
65
84
|
* `resultUnit` at all (the pre-unit-annotation form: a canonical scope in
|
|
@@ -68,17 +87,6 @@ const baseUnitOf = (unit) => (CALCULATOR_UNIT_INFO[unit]?.mathUnit ?? '').split(
|
|
|
68
87
|
export const CANONICAL_BASES = new Set(['um', 'deg']);
|
|
69
88
|
const CANONICAL_LENGTH_BASE = 'um';
|
|
70
89
|
const CANONICAL_ANGLE_BASE = 'deg';
|
|
71
|
-
// Base + length exponent -> the unit that names it. Yards have no length
|
|
72
|
-
// entry (the Units enum has no yard) and liters/gallons no length base at all,
|
|
73
|
-
// so both simply fail to name a unit rather than guessing.
|
|
74
|
-
const UNIT_BY_BASE = {
|
|
75
|
-
mm: { 1: Units.Millimeters, 2: 'sq_mm', 3: 'cu_mm' },
|
|
76
|
-
cm: { 1: Units.Centimeters, 2: 'sq_cm', 3: 'cu_cm' },
|
|
77
|
-
m: { 1: Units.Meters, 2: 'sq_m', 3: 'cu_m' },
|
|
78
|
-
in: { 1: Units.Inches, 2: 'sq_in', 3: 'cu_in' },
|
|
79
|
-
ft: { 1: Units.Feet, 2: 'sq_ft', 3: 'cu_ft' },
|
|
80
|
-
yd: { 2: 'sq_yd', 3: 'cu_yd' },
|
|
81
|
-
};
|
|
82
90
|
const SCALAR = { len: 0, ang: 0 };
|
|
83
91
|
const isScalar = (e) => e.len === 0 && e.ang === 0;
|
|
84
92
|
const sameExponents = (a, b) => a.len === b.len && a.ang === b.ang;
|
|
@@ -143,7 +151,7 @@ export const inferExpressionUnit = (fields, equation) => {
|
|
|
143
151
|
const field = fieldId != null ? fieldById.get(fieldId) : undefined;
|
|
144
152
|
if (field) {
|
|
145
153
|
// No variable unit: the value enters the scope canonically (units.ts).
|
|
146
|
-
const dimension =
|
|
154
|
+
const dimension = bindingDimension(field, equation.variableColumnIds?.[name]);
|
|
147
155
|
const exponents = EXPONENTS_BY_DIMENSION[dimension];
|
|
148
156
|
noteBases(exponents, dimension === 'angle' ? CANONICAL_ANGLE_BASE : CANONICAL_LENGTH_BASE);
|
|
149
157
|
return exponents;
|
|
@@ -300,28 +308,35 @@ export const inferExpressionUnit = (fields, equation) => {
|
|
|
300
308
|
if (!exponents) {
|
|
301
309
|
return { ok: false, reason: failure ?? 'unsupported-operation' };
|
|
302
310
|
}
|
|
311
|
+
// Every success path reports both axes; `bases` narrows to whichever axis
|
|
312
|
+
// the RESULT lives on.
|
|
313
|
+
const axes = { lengthBases: [...lengthBases], angleBases: [...angleBases] };
|
|
303
314
|
if (exponents.ang !== 0) {
|
|
304
315
|
// Angles don't combine with lengths into anything nameable (a length·deg
|
|
305
316
|
// has no unit here), and neither does deg².
|
|
306
317
|
if (exponents.len !== 0 || exponents.ang !== 1) {
|
|
307
318
|
return { ok: false, reason: 'unnameable-dimension' };
|
|
308
319
|
}
|
|
309
|
-
const bases =
|
|
320
|
+
const bases = axes.angleBases;
|
|
310
321
|
return {
|
|
311
322
|
ok: true,
|
|
312
323
|
dimension: 'angle',
|
|
313
324
|
unit: bases.length === 1 ? bases[0] : null,
|
|
314
325
|
bases,
|
|
326
|
+
...axes,
|
|
315
327
|
};
|
|
316
328
|
}
|
|
317
329
|
if (exponents.len === 0) {
|
|
318
|
-
|
|
330
|
+
// Dimensionless. `bases` is empty because the RESULT names no unit — but
|
|
331
|
+
// the per-axis lists still carry what went in, which is the only record
|
|
332
|
+
// that a cancelling ratio mixed inches with feet.
|
|
333
|
+
return { ok: true, dimension: 'none', unit: null, bases: [], ...axes };
|
|
319
334
|
}
|
|
320
335
|
if (exponents.len < 1 || exponents.len > 3) {
|
|
321
336
|
return { ok: false, reason: 'unnameable-dimension' };
|
|
322
337
|
}
|
|
323
338
|
const dimension = exponents.len === 1 ? 'length' : exponents.len === 2 ? 'area' : 'volume';
|
|
324
|
-
const bases =
|
|
339
|
+
const bases = axes.lengthBases;
|
|
325
340
|
return {
|
|
326
341
|
ok: true,
|
|
327
342
|
dimension,
|
|
@@ -329,6 +344,7 @@ export const inferExpressionUnit = (fields, equation) => {
|
|
|
329
344
|
? (UNIT_BY_BASE[bases[0]]?.[exponents.len] ?? null)
|
|
330
345
|
: null,
|
|
331
346
|
bases,
|
|
347
|
+
...axes,
|
|
332
348
|
};
|
|
333
349
|
};
|
|
334
350
|
/**
|
|
@@ -364,73 +380,312 @@ export const deriveResultUnit = (fields, equation) => {
|
|
|
364
380
|
? current
|
|
365
381
|
: inferred.unit;
|
|
366
382
|
};
|
|
383
|
+
// ---------------------------------------------------------------------------
|
|
384
|
+
// Base-anchored derivation.
|
|
385
|
+
//
|
|
386
|
+
// With `CalculatorEquation.base` set, units stop being authored and become a
|
|
387
|
+
// function of (base, binding dimension). `deriveVariableUnits` answers "what
|
|
388
|
+
// does each variable arrive as", `deriveResultUnitForBase` answers "what does
|
|
389
|
+
// the expression hand back", and `deriveEquationBase` picks a base for an
|
|
390
|
+
// equation that predates the field.
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
392
|
+
/** The scope unit for every variable of a base-anchored equation. */
|
|
393
|
+
export const deriveVariableUnits = (base, fields, equation) => {
|
|
394
|
+
const fieldById = new Map(fields.map((f) => [f.id, f]));
|
|
395
|
+
const units = {};
|
|
396
|
+
for (const { variable, fieldId, columnId } of equationBindings(equation)) {
|
|
397
|
+
const field = fieldById.get(fieldId);
|
|
398
|
+
if (!field)
|
|
399
|
+
continue;
|
|
400
|
+
const unit = bindingUnitForBase(base, field, columnId);
|
|
401
|
+
if (unit != null)
|
|
402
|
+
units[variable] = unit;
|
|
403
|
+
}
|
|
404
|
+
return units;
|
|
405
|
+
};
|
|
406
|
+
/** The unit a base-anchored expression's raw value carries. */
|
|
407
|
+
export const deriveResultUnitForBase = (base, fields, equation) => {
|
|
408
|
+
const inferred = inferExpressionUnit(fields, {
|
|
409
|
+
...equation,
|
|
410
|
+
variableUnits: deriveVariableUnits(base, fields, equation),
|
|
411
|
+
});
|
|
412
|
+
if (!inferred.ok)
|
|
413
|
+
return { kind: 'unknown' };
|
|
414
|
+
if (inferred.dimension === 'none' || inferred.dimension === 'angle') {
|
|
415
|
+
return { kind: 'none' };
|
|
416
|
+
}
|
|
417
|
+
const unit = unitForBase(base, inferred.dimension);
|
|
418
|
+
return unit ? { kind: 'unit', unit } : { kind: 'unknown' };
|
|
419
|
+
};
|
|
420
|
+
/** Inert default when an equation has no length-valued binding at all. */
|
|
421
|
+
export const DEFAULT_EQUATION_BASE = 'in';
|
|
422
|
+
const LENGTH_DIMENSIONS = new Set(['length', 'area', 'volume']);
|
|
423
|
+
// Most frequent base wins; ties break toward the first one seen, so the result
|
|
424
|
+
// is stable across runs (variable order is the document's own key order).
|
|
425
|
+
const mostCommon = (bases) => {
|
|
426
|
+
if (bases.length === 0)
|
|
427
|
+
return null;
|
|
428
|
+
const counts = new Map();
|
|
429
|
+
for (const b of bases)
|
|
430
|
+
counts.set(b, (counts.get(b) ?? 0) + 1);
|
|
431
|
+
let best = bases[0];
|
|
432
|
+
for (const b of bases) {
|
|
433
|
+
if ((counts.get(b) ?? 0) > (counts.get(best) ?? 0))
|
|
434
|
+
best = b;
|
|
435
|
+
}
|
|
436
|
+
return best;
|
|
437
|
+
};
|
|
438
|
+
/**
|
|
439
|
+
* The base a pre-`base` equation should adopt, in descending order of what the
|
|
440
|
+
* source can be trusted to mean:
|
|
441
|
+
*
|
|
442
|
+
* 1. A bound conversion COLUMN's unit. A cell reading "5.33" is literal text
|
|
443
|
+
* a human typed; its unit is the only thing that says what the number is,
|
|
444
|
+
* so it is the one declaration in the equation that cannot be re-expressed
|
|
445
|
+
* without reinterpreting authored data.
|
|
446
|
+
* 2. An existing `variableUnits` entry — the author's own stated intent,
|
|
447
|
+
* even if it disagreed with its neighbours.
|
|
448
|
+
* 3. A bound measurement field's display unit. Values are stored canonically,
|
|
449
|
+
* so this only reflects a preference, but it is the author's preference.
|
|
450
|
+
* 4. Nothing length-valued in the equation, so the base cannot matter.
|
|
451
|
+
*
|
|
452
|
+
* Declines entirely (`base: null`) when a variable is ALREADY authored in a
|
|
453
|
+
* unit no base can name — cubic yards, liters, gallons. Re-expressing those is
|
|
454
|
+
* numerically lossless but semantically not: a gravel estimator multiplying a
|
|
455
|
+
* cu_yd volume by a tons-per-cubic-yard constant is correct only while the
|
|
456
|
+
* volume stays in cubic yards, and that constant is a bare number with nothing
|
|
457
|
+
* to declare its units. Silently rebasing it to cu_in would make the answer
|
|
458
|
+
* 46,656× too large. Better to leave such an equation on the pre-base path,
|
|
459
|
+
* where the mixed-base check still watches it, than to guess.
|
|
460
|
+
*/
|
|
461
|
+
export const deriveEquationBase = (fields, equation) => {
|
|
462
|
+
const fieldById = new Map(fields.map((f) => [f.id, f]));
|
|
463
|
+
const fromColumns = [];
|
|
464
|
+
const fromVariableUnits = [];
|
|
465
|
+
const fromFieldDefaults = [];
|
|
466
|
+
const blockedBy = [];
|
|
467
|
+
for (const { variable, fieldId, columnId } of equationBindings(equation)) {
|
|
468
|
+
const field = fieldById.get(fieldId);
|
|
469
|
+
if (!field)
|
|
470
|
+
continue;
|
|
471
|
+
// Only length-family bindings can disagree about scale; angles are their
|
|
472
|
+
// own axis and dimensionless bindings carry nothing.
|
|
473
|
+
if (!LENGTH_DIMENSIONS.has(bindingDimension(field, columnId)))
|
|
474
|
+
continue;
|
|
475
|
+
// bindingDefaultUnit resolves an absent/unknown column id to the first
|
|
476
|
+
// column, matching what the evaluator actually reads.
|
|
477
|
+
const columnUnit = field.kind === ColumnType.ConversionTable
|
|
478
|
+
? bindingDefaultUnit(field, columnId)
|
|
479
|
+
: null;
|
|
480
|
+
const declared = equation.variableUnits?.[variable];
|
|
481
|
+
// The unit this variable is evaluating in TODAY. That is what a base has
|
|
482
|
+
// to be able to reproduce; a field's display default is only a preference.
|
|
483
|
+
const effective = declared ?? columnUnit;
|
|
484
|
+
if (effective != null && equationBaseOfUnit(effective) == null) {
|
|
485
|
+
blockedBy.push(effective);
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
if (columnUnit) {
|
|
489
|
+
const base = equationBaseOfUnit(columnUnit);
|
|
490
|
+
if (base)
|
|
491
|
+
fromColumns.push(base);
|
|
492
|
+
}
|
|
493
|
+
if (declared) {
|
|
494
|
+
const base = equationBaseOfUnit(declared);
|
|
495
|
+
if (base)
|
|
496
|
+
fromVariableUnits.push(base);
|
|
497
|
+
}
|
|
498
|
+
if (field.kind === ColumnType.Measurement) {
|
|
499
|
+
const base = equationBaseOfUnit(field.unit.defaultUnit);
|
|
500
|
+
if (base)
|
|
501
|
+
fromFieldDefaults.push(base);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
if (blockedBy.length > 0) {
|
|
505
|
+
return {
|
|
506
|
+
base: null,
|
|
507
|
+
source: 'unanchorable',
|
|
508
|
+
candidates: [],
|
|
509
|
+
blockedBy: [...new Set(blockedBy)],
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
const tiers = [
|
|
513
|
+
['conversion-column', fromColumns],
|
|
514
|
+
['variable-unit', fromVariableUnits],
|
|
515
|
+
['field-default', fromFieldDefaults],
|
|
516
|
+
];
|
|
517
|
+
for (const [source, candidates] of tiers) {
|
|
518
|
+
const base = mostCommon(candidates);
|
|
519
|
+
if (base)
|
|
520
|
+
return { base, source, candidates: [...new Set(candidates)] };
|
|
521
|
+
}
|
|
522
|
+
return {
|
|
523
|
+
base: DEFAULT_EQUATION_BASE,
|
|
524
|
+
source: 'fallback',
|
|
525
|
+
candidates: [],
|
|
526
|
+
};
|
|
527
|
+
};
|
|
367
528
|
// Rebuild an equation without a key, since Firestore rejects `undefined`.
|
|
368
529
|
const withoutResultUnit = (equation) => {
|
|
369
530
|
const { resultUnit: _dropped, ...rest } = equation;
|
|
370
531
|
return rest;
|
|
371
532
|
};
|
|
533
|
+
// Rebuild an equation without a key, since Firestore rejects `undefined`.
|
|
534
|
+
const withoutVariableColumnIds = (equation) => {
|
|
535
|
+
const { variableColumnIds: _dropped, ...rest } = equation;
|
|
536
|
+
return rest;
|
|
537
|
+
};
|
|
538
|
+
// Rebuild an equation without a key, since Firestore rejects `undefined`.
|
|
539
|
+
const withoutVariableUnits = (equation) => {
|
|
540
|
+
const { variableUnits: _dropped, ...rest } = equation;
|
|
541
|
+
return rest;
|
|
542
|
+
};
|
|
372
543
|
/**
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
|
|
544
|
+
* The pre-base repair, kept verbatim for equations that cannot adopt a base.
|
|
545
|
+
* It only realigns a unit whose DIMENSION no longer matches its binding — it
|
|
546
|
+
* has no opinion about bases, which is precisely why it is safe here: an
|
|
547
|
+
* equation holding cubic yards keeps holding cubic yards.
|
|
548
|
+
*/
|
|
549
|
+
const reconcileLegacyUnits = (fields, fieldById, equation) => {
|
|
550
|
+
let repaired = equation;
|
|
551
|
+
if (repaired.variableUnits) {
|
|
552
|
+
const variableUnits = {};
|
|
553
|
+
let unitsChanged = false;
|
|
554
|
+
for (const [variable, unit] of Object.entries(repaired.variableUnits)) {
|
|
555
|
+
const field = fieldById.get(repaired.variableToFieldId[variable] ?? '');
|
|
556
|
+
if (!field) {
|
|
557
|
+
unitsChanged = true; // variable or field is gone
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
const columnId = repaired.variableColumnIds?.[variable];
|
|
561
|
+
if (unitDimension(unit) === bindingDimension(field, columnId)) {
|
|
562
|
+
variableUnits[variable] = unit;
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
const replacement = bindingDefaultUnit(field, columnId);
|
|
566
|
+
unitsChanged = true;
|
|
567
|
+
if (replacement)
|
|
568
|
+
variableUnits[variable] = replacement;
|
|
569
|
+
}
|
|
570
|
+
if (unitsChanged) {
|
|
571
|
+
repaired =
|
|
572
|
+
Object.keys(variableUnits).length > 0
|
|
573
|
+
? { ...repaired, variableUnits }
|
|
574
|
+
: withoutVariableUnits(repaired);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
const target = fieldById.get(repaired.targetFieldId);
|
|
578
|
+
const targetDimension = target ? fieldDimension(target) : 'none';
|
|
579
|
+
if (targetDimension === 'none') {
|
|
580
|
+
if (repaired.resultUnit != null)
|
|
581
|
+
repaired = withoutResultUnit(repaired);
|
|
582
|
+
return repaired;
|
|
583
|
+
}
|
|
584
|
+
const derived = deriveResultUnit(fields, repaired);
|
|
585
|
+
if (derived != null) {
|
|
586
|
+
if (repaired.resultUnit !== derived) {
|
|
587
|
+
repaired = { ...repaired, resultUnit: derived };
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
else if (repaired.resultUnit != null &&
|
|
591
|
+
unitDimension(repaired.resultUnit) !== targetDimension) {
|
|
592
|
+
// Not derivable AND not even the right dimension: drop it rather than
|
|
593
|
+
// keep converting a length factor into an area field.
|
|
594
|
+
repaired = withoutResultUnit(repaired);
|
|
595
|
+
}
|
|
596
|
+
return repaired;
|
|
597
|
+
};
|
|
598
|
+
/**
|
|
599
|
+
* Bring an equation's field-derived annotations — variable column bindings,
|
|
600
|
+
* variable units, result unit — back in line with the fields it references.
|
|
601
|
+
* Field edits (a dimension switch, a kind change, a deleted conversion column,
|
|
602
|
+
* a deleted field) are written independently of equations, so a stale entry
|
|
603
|
+
* would otherwise keep converting against a unit or reading a column the field
|
|
604
|
+
* no longer has — the failure mode this whole module exists to prevent.
|
|
378
605
|
*/
|
|
379
606
|
export const reconcileEquationUnits = (fields, equations) => {
|
|
380
607
|
const fieldById = new Map(fields.map((f) => [f.id, f]));
|
|
381
608
|
let changed = false;
|
|
382
609
|
const next = equations.map((equation) => {
|
|
383
610
|
let repaired = equation;
|
|
384
|
-
// 1.
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
611
|
+
// 1. Column bindings must name a column the bound field still has. A
|
|
612
|
+
// dropped entry falls back to the first column, which is the same
|
|
613
|
+
// reading the evaluator gives it — so pruning here only removes a lie,
|
|
614
|
+
// it never changes what the equation computes.
|
|
615
|
+
if (equation.variableColumnIds) {
|
|
616
|
+
const variableColumnIds = {};
|
|
617
|
+
let bindingsChanged = false;
|
|
618
|
+
for (const [variable, columnId] of Object.entries(equation.variableColumnIds)) {
|
|
389
619
|
const field = fieldById.get(equation.variableToFieldId[variable] ?? '');
|
|
390
|
-
if (
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
}
|
|
394
|
-
if (unitDimension(unit) === fieldDimension(field)) {
|
|
395
|
-
variableUnits[variable] = unit;
|
|
620
|
+
if (field?.kind === ColumnType.ConversionTable &&
|
|
621
|
+
findConversionColumn(field.columnData, columnId)) {
|
|
622
|
+
variableColumnIds[variable] = columnId;
|
|
396
623
|
continue;
|
|
397
624
|
}
|
|
398
|
-
|
|
399
|
-
unitsChanged = true;
|
|
400
|
-
if (replacement)
|
|
401
|
-
variableUnits[variable] = replacement;
|
|
625
|
+
bindingsChanged = true;
|
|
402
626
|
}
|
|
403
|
-
if (
|
|
627
|
+
if (bindingsChanged) {
|
|
404
628
|
repaired =
|
|
405
|
-
Object.keys(
|
|
406
|
-
? { ...repaired,
|
|
407
|
-
: (
|
|
408
|
-
const { variableUnits: _dropped, ...rest } = repaired;
|
|
409
|
-
return rest;
|
|
410
|
-
})();
|
|
629
|
+
Object.keys(variableColumnIds).length > 0
|
|
630
|
+
? { ...repaired, variableColumnIds }
|
|
631
|
+
: withoutVariableColumnIds(repaired);
|
|
411
632
|
}
|
|
412
633
|
}
|
|
413
|
-
// 2.
|
|
634
|
+
// 2. Adopt a base. An equation authored before bases existed gets one
|
|
635
|
+
// inferred from what it already says (deriveEquationBase), so opening
|
|
636
|
+
// an old calculator and touching a field migrates it in place. An
|
|
637
|
+
// unrecognized stored base is treated as absent rather than trusted.
|
|
638
|
+
// Read against `repaired`: step 1 may have moved a variable back to the
|
|
639
|
+
// first column, whose unit is what the base should follow.
|
|
640
|
+
//
|
|
641
|
+
// An EXPLICIT base always wins — converting a cu_yd column into ft³ is
|
|
642
|
+
// well defined, and an author who asked for it meant it. Only the
|
|
643
|
+
// inference declines, and only for an equation already sitting in a
|
|
644
|
+
// unit family no base can name (see deriveEquationBase).
|
|
645
|
+
const base = isEquationBase(repaired.base)
|
|
646
|
+
? repaired.base
|
|
647
|
+
: deriveEquationBase(fields, repaired).base;
|
|
648
|
+
if (base == null) {
|
|
649
|
+
// Unanchorable: leave it exactly as the pre-base code would have.
|
|
650
|
+
repaired = reconcileLegacyUnits(fields, fieldById, repaired);
|
|
651
|
+
if (repaired !== equation)
|
|
652
|
+
changed = true;
|
|
653
|
+
return repaired;
|
|
654
|
+
}
|
|
655
|
+
if (repaired.base !== base)
|
|
656
|
+
repaired = { ...repaired, base };
|
|
657
|
+
// 3. Variable units are fully determined by the base and each binding's
|
|
658
|
+
// dimension, so they are not stored at all — the evaluator derives them.
|
|
659
|
+
// Anything left over from the pre-base form is dead weight that a reader
|
|
660
|
+
// would reasonably mistake for the source of truth.
|
|
661
|
+
if (repaired.variableUnits != null) {
|
|
662
|
+
repaired = withoutVariableUnits(repaired);
|
|
663
|
+
}
|
|
664
|
+
// 4. The result unit is derived the same way, with ONE exception worth
|
|
665
|
+
// persisting: an expression whose units inference cannot model at all
|
|
666
|
+
// (an unmodelled function, a dimension this vocabulary can't name). The
|
|
667
|
+
// base says nothing useful there, so the author picks, and that choice
|
|
668
|
+
// is real authored data rather than a cache.
|
|
669
|
+
//
|
|
670
|
+
// Number targets take the raw value either way — that is what makes
|
|
671
|
+
// `area_in_ft² / 33` read as 33 ft², not 33 µm².
|
|
414
672
|
const target = fieldById.get(equation.targetFieldId);
|
|
415
673
|
const targetDimension = target ? fieldDimension(target) : 'none';
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
if (
|
|
423
|
-
if (repaired.resultUnit !== derived) {
|
|
424
|
-
repaired = { ...repaired, resultUnit: derived };
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
else if (repaired.resultUnit != null &&
|
|
674
|
+
const derived = targetDimension === 'none'
|
|
675
|
+
? { kind: 'none' }
|
|
676
|
+
: deriveResultUnitForBase(base, fields, repaired);
|
|
677
|
+
if (derived.kind === 'unknown') {
|
|
678
|
+
// Keep the author's pick, unless it isn't even the target's dimension —
|
|
679
|
+
// that would convert a length factor into an area field.
|
|
680
|
+
if (repaired.resultUnit != null &&
|
|
428
681
|
unitDimension(repaired.resultUnit) !== targetDimension) {
|
|
429
|
-
// Not derivable AND not even the right dimension: drop it rather than
|
|
430
|
-
// keep converting a length factor into an area field.
|
|
431
682
|
repaired = withoutResultUnit(repaired);
|
|
432
683
|
}
|
|
433
684
|
}
|
|
685
|
+
else if (repaired.resultUnit != null) {
|
|
686
|
+
// Derivable, so a stored unit is either redundant or wrong. Drop it.
|
|
687
|
+
repaired = withoutResultUnit(repaired);
|
|
688
|
+
}
|
|
434
689
|
if (repaired !== equation)
|
|
435
690
|
changed = true;
|
|
436
691
|
return repaired;
|
package/dist/calculator/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ColumnType, type ColumnConfig, type ConversionTableColumnData, type InstructionsColumnData, type SelectColumnData, type DecimalTolerance, type FractionalTolerance } from '../types/firestore.js';
|
|
2
|
-
import type { AngleUnit, CalculatorUnit, FieldDimension, MeasurementDimension } from './units.js';
|
|
2
|
+
import type { AngleUnit, CalculatorUnit, EquationBase, FieldDimension, MeasurementDimension } from './units.js';
|
|
3
3
|
import type { CalculatorCategoryId } from './categories.js';
|
|
4
4
|
/**
|
|
5
5
|
* Version of the definition format itself. Bumped only on breaking schema
|
|
@@ -112,15 +112,55 @@ export interface CalculatorEquation {
|
|
|
112
112
|
/** Variable letter -> field id. */
|
|
113
113
|
variableToFieldId: Record<string, string>;
|
|
114
114
|
/**
|
|
115
|
-
* Variable letter ->
|
|
116
|
-
*
|
|
117
|
-
*
|
|
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>;
|
|
124
|
+
/**
|
|
125
|
+
* The ONE length base this expression evaluates in ('ft', 'in', 'mm', …) —
|
|
126
|
+
* and, when present, the only unit information the equation stores. Each
|
|
127
|
+
* variable's scope unit is this base at the variable's own exponent (ft,
|
|
128
|
+
* ft², ft³); the result unit is this base at the result's exponent. Both are
|
|
129
|
+
* computed on demand, never written down.
|
|
130
|
+
*
|
|
131
|
+
* This exists because per-variable units could disagree. Authoring
|
|
132
|
+
* `(A_in * B_in) / C_ft²` passed every dimensional check — length·length ÷
|
|
133
|
+
* area cancels to a valid dimensionless count — while computing 144× the
|
|
134
|
+
* truth, because nothing reconciled inches against feet. A single base makes
|
|
135
|
+
* that unrepresentable rather than merely detectable.
|
|
136
|
+
*
|
|
137
|
+
* The base is per EQUATION, not per calculator: values are stored
|
|
138
|
+
* canonically, so two equations may work in different bases and still feed
|
|
139
|
+
* each other — each converts in and out at its own edge.
|
|
140
|
+
*
|
|
141
|
+
* Absent means the pre-base form below. Still read, because saved instances
|
|
142
|
+
* freeze their definition and older docs migrate lazily, but never written
|
|
143
|
+
* for new equations.
|
|
144
|
+
*/
|
|
145
|
+
base?: EquationBase;
|
|
146
|
+
/**
|
|
147
|
+
* LEGACY (pre-`base`). Variable letter -> display unit its value enters the
|
|
148
|
+
* scope in; a mapped variable with no entry stays canonical (µm-scale).
|
|
149
|
+
*
|
|
150
|
+
* Ignored entirely when `base` is set, and stripped by
|
|
151
|
+
* reconcileEquationUnits, since the base determines every one of these — a
|
|
152
|
+
* stored copy could only go stale and be mistaken for the source of truth.
|
|
118
153
|
*/
|
|
119
154
|
variableUnits?: Record<string, CalculatorUnit>;
|
|
120
155
|
/**
|
|
121
156
|
* Display unit the expression's RESULT is expressed in; converted back to
|
|
122
157
|
* canonical before storage/chaining. Only meaningful for measurement/angle
|
|
123
158
|
* targets — number targets take the result raw. Absent = canonical.
|
|
159
|
+
*
|
|
160
|
+
* With `base` set this survives in ONE case: an expression whose units
|
|
161
|
+
* inference cannot model (an unmodelled function, an unnameable dimension).
|
|
162
|
+
* The base yields no answer there, so the author picks one and it is real
|
|
163
|
+
* authored data. Everywhere else it is derived and not stored.
|
|
124
164
|
*/
|
|
125
165
|
resultUnit?: CalculatorUnit;
|
|
126
166
|
}
|
|
@@ -171,12 +211,55 @@ export declare const findEquation: (definition: Pick<CalculatorDefinition, "equa
|
|
|
171
211
|
export declare const equationForField: (definition: Pick<CalculatorDefinition, "equations">, fieldId: string) => CalculatorEquation | undefined;
|
|
172
212
|
/** The dimension a field's numeric value carries. */
|
|
173
213
|
export declare const fieldDimension: (field: CalculatorField) => FieldDimension;
|
|
214
|
+
/**
|
|
215
|
+
* What one equation VARIABLE binds to: a field, and for a conversion table
|
|
216
|
+
* which of its value columns. Everything downstream of the variable map — unit
|
|
217
|
+
* inference, validation, the scope build — needs the pair, because a
|
|
218
|
+
* conversion table's dimension is a property of the column, not the field.
|
|
219
|
+
*/
|
|
220
|
+
export interface VariableBinding {
|
|
221
|
+
fieldId: string;
|
|
222
|
+
/** Undefined for non-conversion fields and for first-column bindings. */
|
|
223
|
+
columnId?: string;
|
|
224
|
+
}
|
|
225
|
+
/** The binding for one variable letter, or null when the letter is unmapped. */
|
|
226
|
+
export declare const equationBinding: (equation: Pick<CalculatorEquation, "variableToFieldId" | "variableColumnIds">, variable: string) => VariableBinding | null;
|
|
227
|
+
/** Every variable's binding, in the variable map's order. */
|
|
228
|
+
export declare const equationBindings: (equation: Pick<CalculatorEquation, "variableToFieldId" | "variableColumnIds">) => (VariableBinding & {
|
|
229
|
+
variable: string;
|
|
230
|
+
})[];
|
|
231
|
+
/**
|
|
232
|
+
* The dimension a BOUND value carries. Same as `fieldDimension` for every kind
|
|
233
|
+
* but a conversion table, where a column may declare a unit and so give the
|
|
234
|
+
* binding a real dimension — the thing that lets `width · depth` off one table
|
|
235
|
+
* row infer as an area rather than a bare product.
|
|
236
|
+
*/
|
|
237
|
+
export declare const bindingDimension: (field: CalculatorField, columnId?: string) => FieldDimension;
|
|
174
238
|
/**
|
|
175
239
|
* The unit a field's value is entered and displayed in, or null for fields
|
|
176
240
|
* that carry no unit. Also the unit an equation referencing the field starts
|
|
177
241
|
* out annotated with.
|
|
178
242
|
*/
|
|
179
243
|
export declare const fieldDefaultUnit: (field: CalculatorField) => CalculatorUnit | null;
|
|
244
|
+
/**
|
|
245
|
+
* The unit a BOUND value is DECLARED in — the conversion column's unit for a
|
|
246
|
+
* table binding (the unit the cell text was typed in), the field's display
|
|
247
|
+
* unit otherwise. This is what the value means at rest, not what it means
|
|
248
|
+
* inside an equation; `bindingUnitForBase` converts it to the latter.
|
|
249
|
+
*/
|
|
250
|
+
export declare const bindingDefaultUnit: (field: CalculatorField, columnId?: string) => CalculatorUnit | null;
|
|
251
|
+
/**
|
|
252
|
+
* The unit a bound value enters a base-anchored equation's scope in — the
|
|
253
|
+
* base's member at the binding's own exponent, so a length arrives in `ft` and
|
|
254
|
+
* an area off the same equation in `ft²`.
|
|
255
|
+
*
|
|
256
|
+
* Never an author choice, and deliberately independent of how the value is
|
|
257
|
+
* declared or displayed: a column typed in ft² feeding an inch-based equation
|
|
258
|
+
* converts to in², which is a lossless re-expression of the same quantity, not
|
|
259
|
+
* a reinterpretation of the author's cell text. Returns null for dimensionless
|
|
260
|
+
* bindings, which enter the scope raw.
|
|
261
|
+
*/
|
|
262
|
+
export declare const bindingUnitForBase: (base: EquationBase, field: CalculatorField, columnId?: string) => CalculatorUnit | null;
|
|
180
263
|
/** Fields whose value participates in equations as a number. */
|
|
181
264
|
export declare const isNumericFieldKind: (kind: CalculatorFieldKind) => kind is ColumnType.Number | ColumnType.Measurement | ColumnType.Angle | ColumnType.ConversionTable;
|
|
182
265
|
/** Field kinds an equation may target (compute into). */
|