@reekon-tools/boldr-utils 1.10.2 → 1.11.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/chips.d.ts +32 -0
- package/dist/calculator/chips.js +79 -0
- package/dist/calculator/display.d.ts +78 -0
- package/dist/calculator/display.js +223 -0
- package/dist/calculator/index.d.ts +3 -0
- package/dist/calculator/index.js +8 -3
- package/dist/calculator/runtime.d.ts +61 -0
- package/dist/calculator/runtime.js +430 -0
- package/dist/types/firestore.d.ts +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ResolvedField } from './runtime.js';
|
|
2
|
+
export declare const CALC_COLORS: {
|
|
3
|
+
/** Ink on the white/light-grey chips. */
|
|
4
|
+
readonly text: "#181A1C";
|
|
5
|
+
readonly chipBg: "#E8EAED";
|
|
6
|
+
readonly chipBorder: "#BCBEC2";
|
|
7
|
+
readonly letterBg: "#E8EAED";
|
|
8
|
+
readonly focusRing: "#FFAD00";
|
|
9
|
+
readonly focusFill: "#FFF8DB";
|
|
10
|
+
/** A value the user (or a tool) typed. */
|
|
11
|
+
readonly entered: "#0066FF";
|
|
12
|
+
readonly enteredLetter: "#50A6FF";
|
|
13
|
+
/** A value the equation solved for. */
|
|
14
|
+
readonly computed: "#BD30A8";
|
|
15
|
+
readonly computedLetter: "#6C155F";
|
|
16
|
+
/**
|
|
17
|
+
* A value that disagrees with its equation. RED, not a fourth blue: blue
|
|
18
|
+
* means entered and purple means calculated, so a navy chip read as a third
|
|
19
|
+
* kind of VALUE rather than as a problem — and its number stayed legible
|
|
20
|
+
* enough to cut against. Red is the app's warning colour everywhere else.
|
|
21
|
+
*/
|
|
22
|
+
readonly conflict: "#E83D2F";
|
|
23
|
+
readonly conflictLetter: "#DA1100";
|
|
24
|
+
readonly conflictClear: "#E83D2F";
|
|
25
|
+
};
|
|
26
|
+
export type ChipState = 'empty' | 'entered' | 'computed' | 'conflict' | 'default';
|
|
27
|
+
export declare const chipStateOf: (resolved: ResolvedField) => ChipState;
|
|
28
|
+
export declare const CHIP_STYLE: Record<ChipState, {
|
|
29
|
+
background: string;
|
|
30
|
+
letterBackground: string;
|
|
31
|
+
text: string;
|
|
32
|
+
}>;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// The value-state palette every calculator surface paints with: mobile's table
|
|
2
|
+
// rows and diagram cards, and the web viewer's read-only cards. It lives here
|
|
3
|
+
// (not in either app) because the COLOR IS THE MEANING — blue says a human
|
|
4
|
+
// typed it, purple says the calculator worked it out, red says the two
|
|
5
|
+
// disagree. A surface that re-derives that mapping locally is one refactor away
|
|
6
|
+
// from telling a crew a computed number was measured.
|
|
7
|
+
//
|
|
8
|
+
// The runtime is a light paper island on both platforms — frame `5287:108428`
|
|
9
|
+
// samples a `#2B2C2E` header over an `#E8EAED` canvas with `#FFFFFF` field
|
|
10
|
+
// cards. That is the exact opposite of the BASIC calculator (`5365:17187`),
|
|
11
|
+
// which is fully dark. Do not "fix" one to match the other.
|
|
12
|
+
import { FormColors, FormulaColors, GeneralColors, RedColors, TileColors, YellowColors, } from '../theme/colors.js';
|
|
13
|
+
export const CALC_COLORS = {
|
|
14
|
+
/** Ink on the white/light-grey chips. */
|
|
15
|
+
text: GeneralColors.darkGrey,
|
|
16
|
+
chipBg: FormColors.lightGrey2,
|
|
17
|
+
chipBorder: FormColors.lightGrey1,
|
|
18
|
+
letterBg: FormColors.lightGrey2,
|
|
19
|
+
focusRing: YellowColors.mainYellow,
|
|
20
|
+
// A pale cream wash for "focused and empty, waiting for the keypad". It only
|
|
21
|
+
// exists on the light canvas and has no palette equivalent — mainYellow at
|
|
22
|
+
// low alpha goes muddy over lightGrey2.
|
|
23
|
+
focusFill: '#FFF8DB',
|
|
24
|
+
/** A value the user (or a tool) typed. */
|
|
25
|
+
entered: TileColors.activeBlue,
|
|
26
|
+
enteredLetter: TileColors.lightBlue1,
|
|
27
|
+
/** A value the equation solved for. */
|
|
28
|
+
computed: FormulaColors.purple,
|
|
29
|
+
computedLetter: FormulaColors.darkPurple,
|
|
30
|
+
/**
|
|
31
|
+
* A value that disagrees with its equation. RED, not a fourth blue: blue
|
|
32
|
+
* means entered and purple means calculated, so a navy chip read as a third
|
|
33
|
+
* kind of VALUE rather than as a problem — and its number stayed legible
|
|
34
|
+
* enough to cut against. Red is the app's warning colour everywhere else.
|
|
35
|
+
*/
|
|
36
|
+
conflict: RedColors.red,
|
|
37
|
+
conflictLetter: RedColors.warningRed,
|
|
38
|
+
conflictClear: RedColors.red,
|
|
39
|
+
};
|
|
40
|
+
export const chipStateOf = (resolved) => {
|
|
41
|
+
if (resolved.conflict)
|
|
42
|
+
return 'conflict';
|
|
43
|
+
if (resolved.origin === 'user' || resolved.origin === 'tool')
|
|
44
|
+
return 'entered';
|
|
45
|
+
if (resolved.origin === 'computed' || resolved.origin === 'solved')
|
|
46
|
+
return 'computed';
|
|
47
|
+
if (resolved.origin === 'default')
|
|
48
|
+
return 'default';
|
|
49
|
+
return 'empty';
|
|
50
|
+
};
|
|
51
|
+
export const CHIP_STYLE = {
|
|
52
|
+
empty: {
|
|
53
|
+
background: GeneralColors.white,
|
|
54
|
+
letterBackground: CALC_COLORS.letterBg,
|
|
55
|
+
text: CALC_COLORS.text,
|
|
56
|
+
},
|
|
57
|
+
default: {
|
|
58
|
+
background: GeneralColors.white,
|
|
59
|
+
letterBackground: CALC_COLORS.letterBg,
|
|
60
|
+
text: CALC_COLORS.text,
|
|
61
|
+
},
|
|
62
|
+
entered: {
|
|
63
|
+
background: CALC_COLORS.entered,
|
|
64
|
+
letterBackground: CALC_COLORS.enteredLetter,
|
|
65
|
+
text: GeneralColors.white,
|
|
66
|
+
},
|
|
67
|
+
computed: {
|
|
68
|
+
background: CALC_COLORS.computed,
|
|
69
|
+
letterBackground: CALC_COLORS.computedLetter,
|
|
70
|
+
text: GeneralColors.white,
|
|
71
|
+
},
|
|
72
|
+
conflict: {
|
|
73
|
+
background: CALC_COLORS.conflict,
|
|
74
|
+
letterBackground: CALC_COLORS.conflictLetter,
|
|
75
|
+
// Full white: the chip shows a dash, and a dash has to be legible. The old
|
|
76
|
+
// 55% wash existed to de-emphasise a number that is no longer drawn.
|
|
77
|
+
text: GeneralColors.white,
|
|
78
|
+
},
|
|
79
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { type CalculatorUnit } from './units.js';
|
|
2
|
+
import type { CalculatorDefinition, CalculatorField } from './schema.js';
|
|
3
|
+
import type { ResolvedField } from './runtime.js';
|
|
4
|
+
export type UnitSystem = 'imperial' | 'metric';
|
|
5
|
+
export type DisplayUnitOverrides = Readonly<Record<string, CalculatorUnit>>;
|
|
6
|
+
/** The unit a field currently displays in (session override > field default). */
|
|
7
|
+
export declare const displayUnitFor: (field: CalculatorField, overrides?: DisplayUnitOverrides) => CalculatorUnit | null;
|
|
8
|
+
/** Units the picker offers for a field; empty for unit-less kinds. */
|
|
9
|
+
export declare const allowedDisplayUnits: (field: CalculatorField) => CalculatorUnit[];
|
|
10
|
+
/**
|
|
11
|
+
* Parse raw keypad text (in the given display unit) into a canonical value.
|
|
12
|
+
* Fractions and mixed numbers are accepted for measurements and angles
|
|
13
|
+
* (mirrors the web editor's EditEquation semantics). Returns null when the
|
|
14
|
+
* draft isn't parseable yet — callers keep the last good value.
|
|
15
|
+
*/
|
|
16
|
+
export declare const parseFieldInput: (field: CalculatorField, unit: CalculatorUnit | null, raw: string) => number | null;
|
|
17
|
+
/** Format a canonical value for display in the given unit. */
|
|
18
|
+
export declare const formatFieldValue: (field: CalculatorField, unit: CalculatorUnit | null, canonical: number) => {
|
|
19
|
+
value: string;
|
|
20
|
+
unit: string;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* What a conflicting field shows INSTEAD of its number. A value the runtime
|
|
24
|
+
* knows disagrees with its equation must not be readable as a measurement —
|
|
25
|
+
* someone cuts to what's on the screen — so the number is withheld rather than
|
|
26
|
+
* dimmed, and the detail sheet is where the entered value is recoverable.
|
|
27
|
+
* Same em dash the empty-output placeholder uses.
|
|
28
|
+
*/
|
|
29
|
+
export declare const CONFLICT_DASH = "\u2014";
|
|
30
|
+
/**
|
|
31
|
+
* The text every value surface shows for a resolved field — table row, diagram
|
|
32
|
+
* stamp, and the detail sheet's card all route through here so the conflict
|
|
33
|
+
* dash can't ship in one of them and not the others. Null means "nothing to
|
|
34
|
+
* show": the caller draws its own empty placeholder.
|
|
35
|
+
*/
|
|
36
|
+
export declare const resolvedValueText: (field: CalculatorField, unit: CalculatorUnit | null, resolved: ResolvedField) => string | null;
|
|
37
|
+
/**
|
|
38
|
+
* The letter chip a field wears in both the diagram badges and the table
|
|
39
|
+
* rows: sequential A, B, C… over the definition's fields (Instructions
|
|
40
|
+
* excluded — they carry no value).
|
|
41
|
+
*/
|
|
42
|
+
export declare const letterLabelFor: (definition: Pick<CalculatorDefinition, "fields">, fieldId: string) => string;
|
|
43
|
+
/**
|
|
44
|
+
* Whether a user-entered value and a freshly computed one count as the same
|
|
45
|
+
* number: they format identically in the field's DEFAULT unit at its
|
|
46
|
+
* configured tolerance (i.e. the user couldn't tell them apart), with a tiny
|
|
47
|
+
* absolute/relative epsilon as a float-noise backstop. Deliberately ignores
|
|
48
|
+
* session unit overrides so conflict-ness doesn't change with the unit
|
|
49
|
+
* toggle.
|
|
50
|
+
*/
|
|
51
|
+
export declare const valuesAgree: (field: CalculatorField, entered: number, computed: number) => boolean;
|
|
52
|
+
/** The structured draft the fraction keypad edits. All parts are digit text. */
|
|
53
|
+
export interface KeypadParts {
|
|
54
|
+
negative: boolean;
|
|
55
|
+
/** Feet, in feet-inches mode only. */
|
|
56
|
+
feet: string;
|
|
57
|
+
/** Whole number (or decimal text in decimal mode). */
|
|
58
|
+
whole: string;
|
|
59
|
+
numerator: string;
|
|
60
|
+
denominator: string;
|
|
61
|
+
}
|
|
62
|
+
export declare const emptyKeypadParts: () => KeypadParts;
|
|
63
|
+
export type KeypadMode = 'decimal' | 'fractional' | 'feetFractional';
|
|
64
|
+
/** Which keypad layout a display unit gets. */
|
|
65
|
+
export declare const keypadModeForUnit: (unit: CalculatorUnit | null) => KeypadMode;
|
|
66
|
+
/**
|
|
67
|
+
* Convert a keypad draft into a canonical value for the field. Returns null
|
|
68
|
+
* when the draft is empty/incomplete (callers keep the previous value).
|
|
69
|
+
* Feet-inches composes feet + fractional inches directly, since a single
|
|
70
|
+
* "F W N/D" string has no parseable representation in a feet unit.
|
|
71
|
+
*/
|
|
72
|
+
export declare const keypadPartsToCanonical: (field: CalculatorField, unit: CalculatorUnit | null, parts: KeypadParts) => number | null;
|
|
73
|
+
/**
|
|
74
|
+
* Overrides implementing the English/Metric toggle: every measurement field
|
|
75
|
+
* flips to its dimension's preferred unit (constrained to allowedUnits).
|
|
76
|
+
* Fields whose pick equals their default get no override. Angles stay put.
|
|
77
|
+
*/
|
|
78
|
+
export declare const mapUnitSystem: (definition: Pick<CalculatorDefinition, "fields">, system: UnitSystem) => Record<string, CalculatorUnit>;
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// Display-unit edge of the calculator runtime: parsing keypad input into
|
|
2
|
+
// canonical values, formatting canonical values back out, per-field unit
|
|
3
|
+
// choices, and the letter labels shared by diagram badges and table rows.
|
|
4
|
+
// Pure functions only, shared by every consuming runtime (mobile's run screen,
|
|
5
|
+
// the web read-only viewer) so a value can't render two ways.
|
|
6
|
+
import { ColumnType, Units } from '../types/firestore.js';
|
|
7
|
+
import { formatCanonical, parseToCanonical, unitsForDimension, } from './units.js';
|
|
8
|
+
/** The unit a field currently displays in (session override > field default). */
|
|
9
|
+
export const displayUnitFor = (field, overrides = {}) => {
|
|
10
|
+
switch (field.kind) {
|
|
11
|
+
case ColumnType.Measurement:
|
|
12
|
+
return overrides[field.id] ?? field.unit.defaultUnit;
|
|
13
|
+
case ColumnType.Angle:
|
|
14
|
+
return overrides[field.id] ?? field.angleUnit ?? 'deg';
|
|
15
|
+
default:
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
/** Units the picker offers for a field; empty for unit-less kinds. */
|
|
20
|
+
export const allowedDisplayUnits = (field) => {
|
|
21
|
+
switch (field.kind) {
|
|
22
|
+
case ColumnType.Measurement: {
|
|
23
|
+
const allowed = field.unit.allowedUnits?.length
|
|
24
|
+
? field.unit.allowedUnits
|
|
25
|
+
: unitsForDimension(field.unit.dimension);
|
|
26
|
+
return allowed.includes(field.unit.defaultUnit)
|
|
27
|
+
? allowed
|
|
28
|
+
: [field.unit.defaultUnit, ...allowed];
|
|
29
|
+
}
|
|
30
|
+
case ColumnType.Angle:
|
|
31
|
+
return ['deg', 'rad'];
|
|
32
|
+
default:
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Parse raw keypad text (in the given display unit) into a canonical value.
|
|
38
|
+
* Fractions and mixed numbers are accepted for measurements and angles
|
|
39
|
+
* (mirrors the web editor's EditEquation semantics). Returns null when the
|
|
40
|
+
* draft isn't parseable yet — callers keep the last good value.
|
|
41
|
+
*/
|
|
42
|
+
export const parseFieldInput = (field, unit, raw) => {
|
|
43
|
+
switch (field.kind) {
|
|
44
|
+
case ColumnType.Measurement:
|
|
45
|
+
case ColumnType.Angle:
|
|
46
|
+
return unit ? parseToCanonical(raw, unit) : null;
|
|
47
|
+
case ColumnType.Number: {
|
|
48
|
+
const trimmed = raw.trim();
|
|
49
|
+
if (trimmed === '')
|
|
50
|
+
return null;
|
|
51
|
+
const parsed = Number(trimmed);
|
|
52
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
53
|
+
}
|
|
54
|
+
default:
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
// Matches the web editor's unit-less number formatting (6 significant
|
|
59
|
+
// figures, no trailing zeros).
|
|
60
|
+
const trimNumber = (value) => Number.isFinite(value) ? Number(value.toPrecision(6)).toString() : 'NaN';
|
|
61
|
+
/** Format a canonical value for display in the given unit. */
|
|
62
|
+
export const formatFieldValue = (field, unit, canonical) => {
|
|
63
|
+
if (!unit)
|
|
64
|
+
return { value: trimNumber(canonical), unit: '' };
|
|
65
|
+
return formatCanonical(canonical, unit, field.kind === ColumnType.Measurement
|
|
66
|
+
? {
|
|
67
|
+
fractionalTolerance: field.unit.fractionalTolerance,
|
|
68
|
+
decimalTolerance: field.unit.decimalTolerance,
|
|
69
|
+
}
|
|
70
|
+
: undefined);
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* What a conflicting field shows INSTEAD of its number. A value the runtime
|
|
74
|
+
* knows disagrees with its equation must not be readable as a measurement —
|
|
75
|
+
* someone cuts to what's on the screen — so the number is withheld rather than
|
|
76
|
+
* dimmed, and the detail sheet is where the entered value is recoverable.
|
|
77
|
+
* Same em dash the empty-output placeholder uses.
|
|
78
|
+
*/
|
|
79
|
+
export const CONFLICT_DASH = '—';
|
|
80
|
+
/**
|
|
81
|
+
* The text every value surface shows for a resolved field — table row, diagram
|
|
82
|
+
* stamp, and the detail sheet's card all route through here so the conflict
|
|
83
|
+
* dash can't ship in one of them and not the others. Null means "nothing to
|
|
84
|
+
* show": the caller draws its own empty placeholder.
|
|
85
|
+
*/
|
|
86
|
+
export const resolvedValueText = (field, unit, resolved) => {
|
|
87
|
+
if (resolved.conflict)
|
|
88
|
+
return CONFLICT_DASH;
|
|
89
|
+
if (resolved.value == null)
|
|
90
|
+
return null;
|
|
91
|
+
if (typeof resolved.value === 'number') {
|
|
92
|
+
return formatFieldValue(field, unit, resolved.value).value;
|
|
93
|
+
}
|
|
94
|
+
return Array.isArray(resolved.value)
|
|
95
|
+
? resolved.value.join(', ')
|
|
96
|
+
: String(resolved.value);
|
|
97
|
+
};
|
|
98
|
+
// 0 -> A, 25 -> Z, 26 -> AA ...
|
|
99
|
+
const indexToLetters = (index) => {
|
|
100
|
+
let label = '';
|
|
101
|
+
let i = index;
|
|
102
|
+
do {
|
|
103
|
+
label = String.fromCharCode(65 + (i % 26)) + label;
|
|
104
|
+
i = Math.floor(i / 26) - 1;
|
|
105
|
+
} while (i >= 0);
|
|
106
|
+
return label;
|
|
107
|
+
};
|
|
108
|
+
/**
|
|
109
|
+
* The letter chip a field wears in both the diagram badges and the table
|
|
110
|
+
* rows: sequential A, B, C… over the definition's fields (Instructions
|
|
111
|
+
* excluded — they carry no value).
|
|
112
|
+
*/
|
|
113
|
+
export const letterLabelFor = (definition, fieldId) => {
|
|
114
|
+
let index = 0;
|
|
115
|
+
for (const field of definition.fields) {
|
|
116
|
+
if (field.kind === ColumnType.Instructions)
|
|
117
|
+
continue;
|
|
118
|
+
if (field.id === fieldId)
|
|
119
|
+
return indexToLetters(index);
|
|
120
|
+
index++;
|
|
121
|
+
}
|
|
122
|
+
return '';
|
|
123
|
+
};
|
|
124
|
+
/**
|
|
125
|
+
* Whether a user-entered value and a freshly computed one count as the same
|
|
126
|
+
* number: they format identically in the field's DEFAULT unit at its
|
|
127
|
+
* configured tolerance (i.e. the user couldn't tell them apart), with a tiny
|
|
128
|
+
* absolute/relative epsilon as a float-noise backstop. Deliberately ignores
|
|
129
|
+
* session unit overrides so conflict-ness doesn't change with the unit
|
|
130
|
+
* toggle.
|
|
131
|
+
*/
|
|
132
|
+
export const valuesAgree = (field, entered, computed) => {
|
|
133
|
+
const unit = displayUnitFor(field, {});
|
|
134
|
+
const enteredText = formatFieldValue(field, unit, entered).value;
|
|
135
|
+
const computedText = formatFieldValue(field, unit, computed).value;
|
|
136
|
+
if (enteredText === computedText)
|
|
137
|
+
return true;
|
|
138
|
+
const epsilon = Math.max(1e-6, 1e-9 * Math.abs(computed));
|
|
139
|
+
return Math.abs(entered - computed) <= epsilon;
|
|
140
|
+
};
|
|
141
|
+
// Preferred display units per dimension for the header's unit-system toggle,
|
|
142
|
+
// best-first. The pick is constrained to each field's allowedUnits.
|
|
143
|
+
const SYSTEM_PREFERENCES = {
|
|
144
|
+
imperial: {
|
|
145
|
+
length: [
|
|
146
|
+
Units.FractionalInches,
|
|
147
|
+
Units.FeetInchesFractional,
|
|
148
|
+
Units.Inches,
|
|
149
|
+
Units.Feet,
|
|
150
|
+
Units.FeetInchesDecimal,
|
|
151
|
+
],
|
|
152
|
+
area: ['sq_ft', 'sq_in', 'sq_yd'],
|
|
153
|
+
volume: ['cu_ft', 'cu_in', 'cu_yd', 'gallon'],
|
|
154
|
+
},
|
|
155
|
+
metric: {
|
|
156
|
+
length: [Units.Millimeters, Units.Centimeters, Units.Meters],
|
|
157
|
+
area: ['sq_m', 'sq_cm', 'sq_mm'],
|
|
158
|
+
volume: ['cu_m', 'liter', 'cu_cm', 'cu_mm'],
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
export const emptyKeypadParts = () => ({
|
|
162
|
+
negative: false,
|
|
163
|
+
feet: '',
|
|
164
|
+
whole: '',
|
|
165
|
+
numerator: '',
|
|
166
|
+
denominator: '',
|
|
167
|
+
});
|
|
168
|
+
/** Which keypad layout a display unit gets. */
|
|
169
|
+
export const keypadModeForUnit = (unit) => {
|
|
170
|
+
if (unit === Units.FractionalInches)
|
|
171
|
+
return 'fractional';
|
|
172
|
+
if (unit === Units.FeetInchesFractional)
|
|
173
|
+
return 'feetFractional';
|
|
174
|
+
return 'decimal';
|
|
175
|
+
};
|
|
176
|
+
const fractionText = (parts) => {
|
|
177
|
+
const { whole, numerator, denominator } = parts;
|
|
178
|
+
const hasFraction = numerator !== '' && denominator !== '' && denominator !== '0';
|
|
179
|
+
const body = hasFraction
|
|
180
|
+
? `${whole || '0'} ${numerator}/${denominator}`
|
|
181
|
+
: whole;
|
|
182
|
+
return body;
|
|
183
|
+
};
|
|
184
|
+
/**
|
|
185
|
+
* Convert a keypad draft into a canonical value for the field. Returns null
|
|
186
|
+
* when the draft is empty/incomplete (callers keep the previous value).
|
|
187
|
+
* Feet-inches composes feet + fractional inches directly, since a single
|
|
188
|
+
* "F W N/D" string has no parseable representation in a feet unit.
|
|
189
|
+
*/
|
|
190
|
+
export const keypadPartsToCanonical = (field, unit, parts) => {
|
|
191
|
+
const sign = parts.negative ? -1 : 1;
|
|
192
|
+
if (keypadModeForUnit(unit) === 'feetFractional') {
|
|
193
|
+
if (parts.feet === '' && fractionText(parts) === '')
|
|
194
|
+
return null;
|
|
195
|
+
const feetUm = parseToCanonical(parts.feet || '0', Units.Feet);
|
|
196
|
+
const inchesUm = parseToCanonical(fractionText(parts) || '0', Units.Inches);
|
|
197
|
+
if (feetUm == null || inchesUm == null)
|
|
198
|
+
return null;
|
|
199
|
+
return sign * (feetUm + inchesUm);
|
|
200
|
+
}
|
|
201
|
+
const text = fractionText(parts);
|
|
202
|
+
if (text === '')
|
|
203
|
+
return null;
|
|
204
|
+
const parsed = parseFieldInput(field, unit, text);
|
|
205
|
+
return parsed == null ? null : sign * parsed;
|
|
206
|
+
};
|
|
207
|
+
/**
|
|
208
|
+
* Overrides implementing the English/Metric toggle: every measurement field
|
|
209
|
+
* flips to its dimension's preferred unit (constrained to allowedUnits).
|
|
210
|
+
* Fields whose pick equals their default get no override. Angles stay put.
|
|
211
|
+
*/
|
|
212
|
+
export const mapUnitSystem = (definition, system) => {
|
|
213
|
+
const overrides = {};
|
|
214
|
+
for (const field of definition.fields) {
|
|
215
|
+
if (field.kind !== ColumnType.Measurement)
|
|
216
|
+
continue;
|
|
217
|
+
const allowed = allowedDisplayUnits(field);
|
|
218
|
+
const pick = SYSTEM_PREFERENCES[system][field.unit.dimension].find((unit) => allowed.includes(unit));
|
|
219
|
+
if (pick && pick !== field.unit.defaultUnit)
|
|
220
|
+
overrides[field.id] = pick;
|
|
221
|
+
}
|
|
222
|
+
return overrides;
|
|
223
|
+
};
|
|
@@ -6,4 +6,7 @@ export * from './expressionUnits.js';
|
|
|
6
6
|
export * from './evaluate.js';
|
|
7
7
|
export * from './solve.js';
|
|
8
8
|
export * from './instance.js';
|
|
9
|
+
export * from './runtime.js';
|
|
10
|
+
export * from './display.js';
|
|
11
|
+
export * from './chips.js';
|
|
9
12
|
export { calculatorDefinitionSchema, expressionSymbols, validateCalculatorDefinition, type IssueSeverity, type ValidationIssue, type ValidationResult, } from './validate.js';
|
package/dist/calculator/index.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// Construction Calculator shared module: the calculator-definition schema,
|
|
2
|
-
// per-field unit system, forward evaluator, two-way numeric solver,
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// per-field unit system, forward evaluator, two-way numeric solver, definition
|
|
3
|
+
// validation, and the consumption-time runtime (derive pass + display
|
|
4
|
+
// formatting + value-state palette) that turns a saved instance into what a
|
|
5
|
+
// screen shows. Pure logic (mathjs + zod only — no Skia/React), safe on web,
|
|
6
|
+
// native, and Node.
|
|
5
7
|
export * from './schema.js';
|
|
6
8
|
export * from './categories.js';
|
|
7
9
|
export * from './units.js';
|
|
@@ -10,4 +12,7 @@ export * from './expressionUnits.js';
|
|
|
10
12
|
export * from './evaluate.js';
|
|
11
13
|
export * from './solve.js';
|
|
12
14
|
export * from './instance.js';
|
|
15
|
+
export * from './runtime.js';
|
|
16
|
+
export * from './display.js';
|
|
17
|
+
export * from './chips.js';
|
|
13
18
|
export { calculatorDefinitionSchema, expressionSymbols, validateCalculatorDefinition, } from './validate.js';
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { type CalculatorDefinition, type CalculatorFieldValue } from './schema.js';
|
|
2
|
+
import type { CalculatorEntryAttribution, CalculatorFieldEntry } from './instance.js';
|
|
3
|
+
export type ResolvedOrigin = 'user' | 'tool' | 'computed' | 'solved' | 'default' | 'empty';
|
|
4
|
+
export interface ResolvedField {
|
|
5
|
+
/** Canonical value to display; null when empty/blocked. */
|
|
6
|
+
value: CalculatorFieldValue | null;
|
|
7
|
+
origin: ResolvedOrigin;
|
|
8
|
+
/**
|
|
9
|
+
* Over-constrained: this entry sits on an equation target whose freshly
|
|
10
|
+
* computed value disagrees beyond the field's display tolerance. Reachable
|
|
11
|
+
* only when the displacement pass below can't move anything — EVERY entry
|
|
12
|
+
* deciding it is locked, or nothing left is invertible. UI: RED chip
|
|
13
|
+
* with the value withheld behind a dash — clearing the entry, or unlocking a
|
|
14
|
+
* participant, resolves it.
|
|
15
|
+
*/
|
|
16
|
+
conflict: {
|
|
17
|
+
computedValue: number;
|
|
18
|
+
} | null;
|
|
19
|
+
/** Eval/solve error attributable to this field's equation. */
|
|
20
|
+
error: string | null;
|
|
21
|
+
/** When its equation is blocked: the field ids still needed. */
|
|
22
|
+
missingFieldIds: string[];
|
|
23
|
+
/** Which equation computed/solved this value. */
|
|
24
|
+
viaEquationId: string | null;
|
|
25
|
+
locked: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Populating this field can only end in a conflict, so entry is refused
|
|
28
|
+
* outright (Asana 1217172568173859). True when the row is showing a
|
|
29
|
+
* CALCULATED value and every other field on its governing equation is
|
|
30
|
+
* entered AND locked: the displacement pass then has nothing left to
|
|
31
|
+
* yield, so a typed value would land as the red conflict chip instead of
|
|
32
|
+
* steering the calculation. Unlocking any one of them clears it.
|
|
33
|
+
*/
|
|
34
|
+
populationBlocked: boolean;
|
|
35
|
+
attribution: CalculatorEntryAttribution | null;
|
|
36
|
+
}
|
|
37
|
+
export interface EquationStatus {
|
|
38
|
+
equationId: string;
|
|
39
|
+
targetFieldId: string;
|
|
40
|
+
/** Variables ∪ target — drives the link badges on participating rows. */
|
|
41
|
+
participantFieldIds: string[];
|
|
42
|
+
state: 'computed' | 'solved-a-field' | 'blocked' | 'error';
|
|
43
|
+
/** The field this equation was inverted for (wears the '=' badge). */
|
|
44
|
+
solvedFieldId: string | null;
|
|
45
|
+
}
|
|
46
|
+
export interface DerivedRuntime {
|
|
47
|
+
resolved: Record<string, ResolvedField>;
|
|
48
|
+
equations: EquationStatus[];
|
|
49
|
+
/** fieldId -> equation ids it participates in (focus highlighting). */
|
|
50
|
+
equationsByField: Record<string, string[]>;
|
|
51
|
+
}
|
|
52
|
+
export declare const EMPTY_DERIVED: DerivedRuntime;
|
|
53
|
+
export declare const deriveRuntime: (definition: Pick<CalculatorDefinition, "fields" | "equations">, entries: Readonly<Record<string, CalculatorFieldEntry>>, options?: {
|
|
54
|
+
solveEnabled?: boolean;
|
|
55
|
+
}) => DerivedRuntime;
|
|
56
|
+
/**
|
|
57
|
+
* `populationBlocked` for a single field — the one rule the reducer's guards
|
|
58
|
+
* and the UI's entry affordances share, so a refused "+" and a refused tape
|
|
59
|
+
* reading can never disagree about which fields are enterable.
|
|
60
|
+
*/
|
|
61
|
+
export declare const isPopulationBlocked: (definition: Pick<CalculatorDefinition, "fields" | "equations">, entries: Readonly<Record<string, CalculatorFieldEntry>>, fieldId: string) => boolean;
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
// The derive pass: everything the calculator UI shows that isn't a raw entry.
|
|
2
|
+
// deriveRuntime(definition, entries) is a pure function — computed outputs,
|
|
3
|
+
// solved fields, conflicts, equation badges all fall out of it, recomputed
|
|
4
|
+
// after every mutation. Nothing here is stored, so it can never go stale.
|
|
5
|
+
//
|
|
6
|
+
// This is the ENGINE both runtimes share: mobile's editable run screen and the
|
|
7
|
+
// web read-only viewer. A saved instance therefore re-derives identically on
|
|
8
|
+
// either platform, which is the whole point — the numbers a crew reads on the
|
|
9
|
+
// desktop preview are the numbers the phone showed when it saved.
|
|
10
|
+
import { ColumnType } from '../types/firestore.js';
|
|
11
|
+
import { equationForField, findField, } from './schema.js';
|
|
12
|
+
import { evaluateOutputs } from './evaluate.js';
|
|
13
|
+
import { solveForField } from './solve.js';
|
|
14
|
+
import { valuesAgree } from './display.js';
|
|
15
|
+
export const EMPTY_DERIVED = {
|
|
16
|
+
resolved: {},
|
|
17
|
+
equations: [],
|
|
18
|
+
equationsByField: {},
|
|
19
|
+
};
|
|
20
|
+
const EMPTY_RESOLVED = {
|
|
21
|
+
conflict: null,
|
|
22
|
+
error: null,
|
|
23
|
+
missingFieldIds: [],
|
|
24
|
+
viaEquationId: null,
|
|
25
|
+
locked: false,
|
|
26
|
+
populationBlocked: false,
|
|
27
|
+
attribution: null,
|
|
28
|
+
};
|
|
29
|
+
const solveHintsOf = (field) => field.kind === ColumnType.Number ||
|
|
30
|
+
field.kind === ColumnType.Measurement ||
|
|
31
|
+
field.kind === ColumnType.Angle
|
|
32
|
+
? field.solve
|
|
33
|
+
: undefined;
|
|
34
|
+
const entryOrigin = (entry) => entry.source === 'tool' ? 'tool' : 'user';
|
|
35
|
+
const isNumericField = (field) => field?.kind === ColumnType.Number ||
|
|
36
|
+
field?.kind === ColumnType.Measurement ||
|
|
37
|
+
field?.kind === ColumnType.Angle;
|
|
38
|
+
/** Variables ∪ target — the fields an equation ties together. */
|
|
39
|
+
const participantsOf = (equation) => [
|
|
40
|
+
...new Set([
|
|
41
|
+
...Object.values(equation.variableToFieldId),
|
|
42
|
+
equation.targetFieldId,
|
|
43
|
+
]),
|
|
44
|
+
];
|
|
45
|
+
/**
|
|
46
|
+
* The definition as the solver should see it while inverting `equation` for
|
|
47
|
+
* `fieldId` under the displacement rule below: whether this field may be
|
|
48
|
+
* calculated is a RUNTIME question there (the user left it unlocked), so the
|
|
49
|
+
* authored hints contribute their brackets but not their veto.
|
|
50
|
+
*/
|
|
51
|
+
const definitionForDisplacement = (definition, fieldId, equation) => ({
|
|
52
|
+
equations: definition.equations,
|
|
53
|
+
fields: definition.fields.map((field) => {
|
|
54
|
+
if (field.id !== fieldId)
|
|
55
|
+
return field;
|
|
56
|
+
if (field.kind !== ColumnType.Number &&
|
|
57
|
+
field.kind !== ColumnType.Measurement &&
|
|
58
|
+
field.kind !== ColumnType.Angle) {
|
|
59
|
+
return field;
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
...field,
|
|
63
|
+
solve: {
|
|
64
|
+
...field.solve,
|
|
65
|
+
solvable: true,
|
|
66
|
+
governingEquationId: equation.id,
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}),
|
|
70
|
+
});
|
|
71
|
+
export const deriveRuntime = (definition, entries, options) => {
|
|
72
|
+
const solveEnabled = options?.solveEnabled ?? true;
|
|
73
|
+
// 1. Project entries to canonical values. Defaults are NOT merged here —
|
|
74
|
+
// numericFieldValue falls back to field.defaultValue during evaluation
|
|
75
|
+
// and solving, so equations see them for free.
|
|
76
|
+
const values = {};
|
|
77
|
+
for (const [fieldId, entry] of Object.entries(entries)) {
|
|
78
|
+
values[fieldId] = entry.value;
|
|
79
|
+
}
|
|
80
|
+
const fieldOrder = new Map(definition.fields.map((f, index) => [f.id, index]));
|
|
81
|
+
/** fieldId -> equation ids it participates in. */
|
|
82
|
+
const equationsByField = {};
|
|
83
|
+
for (const equation of definition.equations) {
|
|
84
|
+
for (const fieldId of participantsOf(equation)) {
|
|
85
|
+
(equationsByField[fieldId] ?? (equationsByField[fieldId] = [])).push(equation.id);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const equationById = new Map(definition.equations.map((e) => [e.id, e]));
|
|
89
|
+
const equationTargeting = (fieldId) => definition.equations.find((e) => e.targetFieldId === fieldId);
|
|
90
|
+
/**
|
|
91
|
+
* Entries the displacement rule set aside. Shadowing is PRESENTATION: the
|
|
92
|
+
* entry stays in state untouched, so clearing whatever over-determined the
|
|
93
|
+
* equation brings the original number straight back, and a saved instance
|
|
94
|
+
* re-derives identically on reopen.
|
|
95
|
+
*/
|
|
96
|
+
const shadowed = new Set();
|
|
97
|
+
/** A value the user is currently holding — an entry no shadow has set aside. */
|
|
98
|
+
const isPinned = (shadows, fieldId) => entries[fieldId] != null && !shadows.has(fieldId);
|
|
99
|
+
/** Entries (minus shadows) ∪ solved — what the runtime knows right now. */
|
|
100
|
+
const knownValues = (shadows, solved) => {
|
|
101
|
+
const known = {};
|
|
102
|
+
for (const [id, value] of Object.entries(values)) {
|
|
103
|
+
if (!shadows.has(id))
|
|
104
|
+
known[id] = value;
|
|
105
|
+
}
|
|
106
|
+
for (const [id, s] of Object.entries(solved))
|
|
107
|
+
known[id] = s.value;
|
|
108
|
+
return known;
|
|
109
|
+
};
|
|
110
|
+
// 2. Solve pass. Two halves: PROPAGATION works out everything derivable from
|
|
111
|
+
// the values the user is holding, and DISPLACEMENT decides which held
|
|
112
|
+
// values have to give way when they can't all hold at once.
|
|
113
|
+
/**
|
|
114
|
+
* Every value the runtime can pin down for a given shadow set, to a fixpoint
|
|
115
|
+
* so each filled-in value cascades into the next equation. Two rules:
|
|
116
|
+
*
|
|
117
|
+
* - a SHADOWED field is taken over by the first equation that can invert for
|
|
118
|
+
* it. The lock is the gate the user actually operates, so the authored
|
|
119
|
+
* `solvable` hint gets no veto here — it only lends its brackets (see
|
|
120
|
+
* definitionForDisplacement). Which equation ends up with the field is not
|
|
121
|
+
* fixed: releasing another entry can hand it to a different one, which is
|
|
122
|
+
* exactly how the newest entry takes the wheel below.
|
|
123
|
+
* - an EMPTY field fills itself in only when its author marked it solvable.
|
|
124
|
+
* solveForField enforces the exactly-one-missing rule (missing-target /
|
|
125
|
+
* missing-inputs fail silently), so under-determined systems stay empty.
|
|
126
|
+
*
|
|
127
|
+
* Pure in `shadows`, so displacement can try a release and throw it away.
|
|
128
|
+
*/
|
|
129
|
+
const propagate = (shadows) => {
|
|
130
|
+
const solved = {};
|
|
131
|
+
let progressed = true;
|
|
132
|
+
let sweeps = 0;
|
|
133
|
+
while (progressed && sweeps++ <= definition.fields.length) {
|
|
134
|
+
progressed = false;
|
|
135
|
+
for (const field of definition.fields) {
|
|
136
|
+
if (solved[field.id] || isPinned(shadows, field.id))
|
|
137
|
+
continue;
|
|
138
|
+
if (!shadows.has(field.id)) {
|
|
139
|
+
const hints = solveHintsOf(field);
|
|
140
|
+
if (!hints?.solvable || !hints.governingEquationId)
|
|
141
|
+
continue;
|
|
142
|
+
const result = solveForField({
|
|
143
|
+
definition,
|
|
144
|
+
fieldId: field.id,
|
|
145
|
+
values: knownValues(shadows, solved),
|
|
146
|
+
});
|
|
147
|
+
if (result.ok) {
|
|
148
|
+
solved[field.id] = {
|
|
149
|
+
value: result.value,
|
|
150
|
+
viaEquationId: hints.governingEquationId,
|
|
151
|
+
};
|
|
152
|
+
progressed = true;
|
|
153
|
+
}
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
// A displaced equation TARGET needs no inversion — the forward pass
|
|
157
|
+
// (step 3) recomputes it from everything else.
|
|
158
|
+
if (equationTargeting(field.id) || !isNumericField(field))
|
|
159
|
+
continue;
|
|
160
|
+
for (const equationId of equationsByField[field.id] ?? []) {
|
|
161
|
+
const equation = equationById.get(equationId);
|
|
162
|
+
if (!equation)
|
|
163
|
+
continue;
|
|
164
|
+
const result = solveForField({
|
|
165
|
+
definition: definitionForDisplacement(definition, field.id, equation),
|
|
166
|
+
fieldId: field.id,
|
|
167
|
+
values: knownValues(shadows, solved),
|
|
168
|
+
});
|
|
169
|
+
if (!result.ok)
|
|
170
|
+
continue;
|
|
171
|
+
solved[field.id] = { value: result.value, viaEquationId: equationId };
|
|
172
|
+
progressed = true;
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return solved;
|
|
178
|
+
};
|
|
179
|
+
const evaluateAll = (shadows, solved) => evaluateOutputs({ definition, values: knownValues(shadows, solved) });
|
|
180
|
+
/**
|
|
181
|
+
* Can this equation still be satisfied as things stand? Two ways it can't,
|
|
182
|
+
* and both are asking the same question — is there anywhere left to put the
|
|
183
|
+
* answer:
|
|
184
|
+
*
|
|
185
|
+
* (i) every participant is pinned by an entry, so the equation has no free
|
|
186
|
+
* field to calculate (Asana 1217042186684890 — filling in the last
|
|
187
|
+
* field should show a result, not demand one be cleared by hand);
|
|
188
|
+
* (ii) its target is pinned and disagrees with what the rest of the
|
|
189
|
+
* calculator makes of it. That's the red conflict chip, caught one step
|
|
190
|
+
* before step 4 draws it — and unlike (i) it fires even when the
|
|
191
|
+
* disagreement arrives through a field ANOTHER equation solved.
|
|
192
|
+
*/
|
|
193
|
+
const isOverConstrained = (equation, shadows, results) => {
|
|
194
|
+
if (participantsOf(equation).every((id) => isPinned(shadows, id)))
|
|
195
|
+
return true;
|
|
196
|
+
if (!isPinned(shadows, equation.targetFieldId))
|
|
197
|
+
return false;
|
|
198
|
+
const target = findField(definition, equation.targetFieldId);
|
|
199
|
+
const result = results[equation.targetFieldId];
|
|
200
|
+
const entered = entries[equation.targetFieldId].value;
|
|
201
|
+
return (target != null &&
|
|
202
|
+
result?.ok === true &&
|
|
203
|
+
typeof entered === 'number' &&
|
|
204
|
+
!valuesAgree(target, entered, result.value));
|
|
205
|
+
};
|
|
206
|
+
/**
|
|
207
|
+
* Every field whose entry decides this equation's value: its participants,
|
|
208
|
+
* plus — transitively — the participants of whatever equation supplied a
|
|
209
|
+
* participant the user isn't holding.
|
|
210
|
+
*
|
|
211
|
+
* The reach matters (Asana 1217332117501004). In the reported cuboid, the
|
|
212
|
+
* surface-area equation reads a length the VOLUME equation solved, so the
|
|
213
|
+
* stale volume entry is what has to give way — and it appears nowhere in the
|
|
214
|
+
* surface-area equation itself. Walking only the participants leaves the
|
|
215
|
+
* newest entry with nothing it may displace, and it lands as a red chip.
|
|
216
|
+
*/
|
|
217
|
+
const determinants = (equation, shadows, solved) => {
|
|
218
|
+
const fields = new Set();
|
|
219
|
+
const visited = new Set();
|
|
220
|
+
const queue = [equation.id];
|
|
221
|
+
while (queue.length > 0) {
|
|
222
|
+
const equationId = queue.pop();
|
|
223
|
+
if (visited.has(equationId))
|
|
224
|
+
continue;
|
|
225
|
+
visited.add(equationId);
|
|
226
|
+
for (const fieldId of participantsOf(equationById.get(equationId) ?? equation)) {
|
|
227
|
+
fields.add(fieldId);
|
|
228
|
+
// A held value is decided by its own entry and nothing further back.
|
|
229
|
+
if (isPinned(shadows, fieldId))
|
|
230
|
+
continue;
|
|
231
|
+
const via = solved[fieldId]?.viaEquationId ?? equationTargeting(fieldId)?.id;
|
|
232
|
+
if (via)
|
|
233
|
+
queue.push(via);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return [...fields];
|
|
237
|
+
};
|
|
238
|
+
let solved = solveEnabled ? propagate(shadowed) : {};
|
|
239
|
+
let results = evaluateAll(shadowed, solved);
|
|
240
|
+
if (solveEnabled) {
|
|
241
|
+
// DISPLACEMENT. While some equation can't be satisfied, the OLDEST unlocked
|
|
242
|
+
// entry deciding it yields to the calculation.
|
|
243
|
+
//
|
|
244
|
+
// Every lock the user set is a value they asked to hold, so what's left
|
|
245
|
+
// unlocked is what the calculation may move; age breaks the tie, oldest
|
|
246
|
+
// first, because the newer number is the better statement of intent.
|
|
247
|
+
//
|
|
248
|
+
// The NEWEST entry is spared: it's what the user just typed, and
|
|
249
|
+
// calculating over it would swallow the keystroke. The one exception is
|
|
250
|
+
// when it is the ONLY thing left unlocked — sparing it there just paints
|
|
251
|
+
// the red chip and makes them clear it by hand. Locking that last free
|
|
252
|
+
// field is what pins the whole equation and brings the chip back.
|
|
253
|
+
//
|
|
254
|
+
// A release only sticks if it lands: the field has to end up calculated AND
|
|
255
|
+
// the equation actually satisfied. Otherwise it's rolled back and the next
|
|
256
|
+
// oldest tries, then the next — and when none of them work, the conflict
|
|
257
|
+
// chip stands, which is the honest answer.
|
|
258
|
+
for (let round = 0; round <= definition.fields.length; round++) {
|
|
259
|
+
let released = false;
|
|
260
|
+
for (const equation of definition.equations) {
|
|
261
|
+
if (!isOverConstrained(equation, shadowed, results))
|
|
262
|
+
continue;
|
|
263
|
+
const byAge = determinants(equation, shadowed, solved)
|
|
264
|
+
.filter((id) => isPinned(shadowed, id) && !entries[id].locked)
|
|
265
|
+
.sort((a, b) => (entries[a].enteredAt ?? 0) - (entries[b].enteredAt ?? 0) ||
|
|
266
|
+
(fieldOrder.get(a) ?? 0) - (fieldOrder.get(b) ?? 0));
|
|
267
|
+
const candidates = byAge.length === 1 ? byAge : byAge.slice(0, -1);
|
|
268
|
+
for (const candidate of candidates) {
|
|
269
|
+
const trial = new Set(shadowed).add(candidate);
|
|
270
|
+
const trialSolved = propagate(trial);
|
|
271
|
+
const trialResults = evaluateAll(trial, trialSolved);
|
|
272
|
+
const calculated = trialSolved[candidate] != null ||
|
|
273
|
+
trialResults[candidate]?.ok === true;
|
|
274
|
+
if (!calculated)
|
|
275
|
+
continue;
|
|
276
|
+
if (isOverConstrained(equation, trial, trialResults))
|
|
277
|
+
continue;
|
|
278
|
+
shadowed.add(candidate);
|
|
279
|
+
solved = trialSolved;
|
|
280
|
+
results = trialResults;
|
|
281
|
+
released = true;
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
// One release re-decides which equation owns which field, so rescan
|
|
285
|
+
// from the top rather than carrying on with stale statuses.
|
|
286
|
+
if (released)
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
if (!released)
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
// 3. The forward pass over entries ∪ solved (`results`, above) serves both
|
|
294
|
+
// output display and conflict detection.
|
|
295
|
+
// 4. Resolve each field.
|
|
296
|
+
const resolved = {};
|
|
297
|
+
for (const field of definition.fields) {
|
|
298
|
+
// A shadowed entry resolves exactly as if the field were empty — computed
|
|
299
|
+
// if it's an equation target, solved otherwise — so the row shows the
|
|
300
|
+
// calculated value, not the number the displacement rule set aside.
|
|
301
|
+
const entry = shadowed.has(field.id) ? undefined : entries[field.id];
|
|
302
|
+
const equation = equationForField(definition, field.id);
|
|
303
|
+
const result = equation ? results[field.id] : undefined;
|
|
304
|
+
const base = {
|
|
305
|
+
...EMPTY_RESOLVED,
|
|
306
|
+
value: null,
|
|
307
|
+
origin: 'empty',
|
|
308
|
+
locked: entry?.locked ?? false,
|
|
309
|
+
attribution: entry?.attribution ?? null,
|
|
310
|
+
};
|
|
311
|
+
if (field.kind === ColumnType.Instructions) {
|
|
312
|
+
resolved[field.id] = base;
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (equation && result) {
|
|
316
|
+
if (result.ok) {
|
|
317
|
+
if (entry && typeof entry.value === 'number') {
|
|
318
|
+
resolved[field.id] = {
|
|
319
|
+
...base,
|
|
320
|
+
value: entry.value,
|
|
321
|
+
origin: entryOrigin(entry),
|
|
322
|
+
viaEquationId: equation.id,
|
|
323
|
+
conflict: valuesAgree(field, entry.value, result.value)
|
|
324
|
+
? null
|
|
325
|
+
: { computedValue: result.value },
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
resolved[field.id] = {
|
|
330
|
+
...base,
|
|
331
|
+
value: result.value,
|
|
332
|
+
origin: 'computed',
|
|
333
|
+
viaEquationId: equation.id,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
else if (result.reason === 'missing-inputs') {
|
|
338
|
+
resolved[field.id] = entry
|
|
339
|
+
? { ...base, value: entry.value, origin: entryOrigin(entry) }
|
|
340
|
+
: { ...base, missingFieldIds: result.missingFieldIds };
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
resolved[field.id] = {
|
|
344
|
+
...base,
|
|
345
|
+
value: entry?.value ?? null,
|
|
346
|
+
origin: entry ? entryOrigin(entry) : 'empty',
|
|
347
|
+
error: result.error,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (entry) {
|
|
353
|
+
resolved[field.id] = {
|
|
354
|
+
...base,
|
|
355
|
+
value: entry.value,
|
|
356
|
+
origin: entryOrigin(entry),
|
|
357
|
+
};
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
const solvedField = solved[field.id];
|
|
361
|
+
if (solvedField) {
|
|
362
|
+
resolved[field.id] = {
|
|
363
|
+
...base,
|
|
364
|
+
value: solvedField.value,
|
|
365
|
+
origin: 'solved',
|
|
366
|
+
viaEquationId: solvedField.viaEquationId,
|
|
367
|
+
};
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
// Instructions were handled above, so every remaining kind may carry one.
|
|
371
|
+
if (field.defaultValue != null) {
|
|
372
|
+
resolved[field.id] = {
|
|
373
|
+
...base,
|
|
374
|
+
value: field.defaultValue,
|
|
375
|
+
origin: 'default',
|
|
376
|
+
};
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
resolved[field.id] = base;
|
|
380
|
+
}
|
|
381
|
+
// 4b. Which calculated fields refuse entry (see ResolvedField.populationBlocked).
|
|
382
|
+
// A separate pass because it reads the resolved origin: only a field the
|
|
383
|
+
// runtime is CURRENTLY calculating can be blocked — an empty one is just
|
|
384
|
+
// waiting for a value, and one showing its own entry can be retyped or
|
|
385
|
+
// cleared.
|
|
386
|
+
for (const field of definition.fields) {
|
|
387
|
+
const current = resolved[field.id];
|
|
388
|
+
if (current.origin !== 'computed' && current.origin !== 'solved')
|
|
389
|
+
continue;
|
|
390
|
+
const equation = definition.equations.find((candidate) => candidate.id === current.viaEquationId);
|
|
391
|
+
if (!equation)
|
|
392
|
+
continue;
|
|
393
|
+
const others = participantsOf(equation).filter((id) => id !== field.id);
|
|
394
|
+
// `every` over the ENTERED test as well: a participant left to a default
|
|
395
|
+
// (no entry at all) can't be unlocked, so telling the user to unlock
|
|
396
|
+
// something wouldn't be true — that case keeps the conflict chip.
|
|
397
|
+
if (others.length > 0 &&
|
|
398
|
+
others.every((id) => entries[id]?.locked === true)) {
|
|
399
|
+
resolved[field.id] = { ...current, populationBlocked: true };
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
// 5. Equation statuses for the link/'=' badges.
|
|
403
|
+
const equations = definition.equations.map((equation) => {
|
|
404
|
+
const participantFieldIds = participantsOf(equation);
|
|
405
|
+
const solvedFieldId = Object.entries(solved).find(([, s]) => s.viaEquationId === equation.id)?.[0] ?? null;
|
|
406
|
+
const result = results[equation.targetFieldId];
|
|
407
|
+
const state = solvedFieldId
|
|
408
|
+
? 'solved-a-field'
|
|
409
|
+
: result?.ok
|
|
410
|
+
? 'computed'
|
|
411
|
+
: result && !result.ok && result.reason === 'missing-inputs'
|
|
412
|
+
? 'blocked'
|
|
413
|
+
: 'error';
|
|
414
|
+
return {
|
|
415
|
+
equationId: equation.id,
|
|
416
|
+
targetFieldId: equation.targetFieldId,
|
|
417
|
+
participantFieldIds,
|
|
418
|
+
state,
|
|
419
|
+
solvedFieldId,
|
|
420
|
+
};
|
|
421
|
+
});
|
|
422
|
+
return { resolved, equations, equationsByField };
|
|
423
|
+
};
|
|
424
|
+
/**
|
|
425
|
+
* `populationBlocked` for a single field — the one rule the reducer's guards
|
|
426
|
+
* and the UI's entry affordances share, so a refused "+" and a refused tape
|
|
427
|
+
* reading can never disagree about which fields are enterable.
|
|
428
|
+
*/
|
|
429
|
+
export const isPopulationBlocked = (definition, entries, fieldId) => deriveRuntime(definition, entries).resolved[fieldId]?.populationBlocked ===
|
|
430
|
+
true;
|