formlab-mcp 0.6.49 → 0.6.50
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/README.md +3 -3
- package/package.json +1 -1
- package/server.json +2 -2
- package/theoretical.js +81 -0
- package/tools/formulations.js +4 -1
- package/tools/ingredients.js +5 -1
- package/tools/lab.js +3 -1
- package/vendor/README.md +18 -0
- package/vendor/cost.js +2619 -0
- package/vendor/nesting.js +323 -0
- package/vendor/theoretical-params.js +1559 -0
|
@@ -0,0 +1,1559 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// THEORETICAL PARAMETERS
|
|
3
|
+
// ============================================================
|
|
4
|
+
// Computed scalar values that hang off a Formulation (and inherit
|
|
5
|
+
// onto its Batches). Each parameter has a formula like
|
|
6
|
+
// SUM(row.amount * ing.solids) / SUM(row.amount)
|
|
7
|
+
// that iterates the composition rows and pulls properties from the
|
|
8
|
+
// ingredient library. Answers the recurring "calculate NV/OHV/PVC/VOC
|
|
9
|
+
// before lab production" ask from coatings/resins/specialty-chem
|
|
10
|
+
// shops (Allnex, Morgan, BASF all flagged it).
|
|
11
|
+
//
|
|
12
|
+
// Architecture:
|
|
13
|
+
// - Parameters live on Formulation (template) and inherit to Batch
|
|
14
|
+
// - Batches can override the formula list (mirrors Procedure pattern)
|
|
15
|
+
// - Formula language: SUM/AVG/MIN/MAX/COUNT with optional WHERE,
|
|
16
|
+
// plus row.* (composition row fields) and ing.* (ingredient lib)
|
|
17
|
+
// - Fresh small parser (~120 lines) — separate from the DOE Matrix
|
|
18
|
+
// computed-column engine, which only does [Col]/[Other] arithmetic
|
|
19
|
+
|
|
20
|
+
// ---- Formula language ----
|
|
21
|
+
//
|
|
22
|
+
// Aggregations: SUM, AVG, MEAN, MIN, MAX, COUNT
|
|
23
|
+
// SUM(expr) — over all composition rows
|
|
24
|
+
// SUM(expr WHERE cond) — over filtered rows
|
|
25
|
+
// COUNT() — count all rows
|
|
26
|
+
// COUNT(WHERE cond) — count filtered rows
|
|
27
|
+
//
|
|
28
|
+
// Row references (only valid inside aggregation bodies):
|
|
29
|
+
// row.amount, row.weight — the row's numeric amount
|
|
30
|
+
// row.unit, row.role — string fields
|
|
31
|
+
// ing.density, ing.mw, ing.cost, — standard ingredient fields
|
|
32
|
+
// ing.purity, — % active (technical/impure actives)
|
|
33
|
+
// ing.grade, ing.supplier, ing.function, ing.state, ing.name
|
|
34
|
+
// (ing.function is multi-valued: `ing.function == 'Binder'` matches
|
|
35
|
+
// any of its pipe-separated labels, case-insensitively)
|
|
36
|
+
// ing.solids, ing['NV %'], ing.X — any custom field by name
|
|
37
|
+
//
|
|
38
|
+
// Outside aggregations: arithmetic only (+ - * / parens, numbers,
|
|
39
|
+
// constants PI/E). Bare row./ing. outside an aggregation throws.
|
|
40
|
+
//
|
|
41
|
+
// Cross-parameter references:
|
|
42
|
+
// [Param Name] — another parameter's computed value
|
|
43
|
+
// e.g. [Total cost] / [Total weight]
|
|
44
|
+
// Resolved in dependency order before compile (see _tpEvaluateAll);
|
|
45
|
+
// cycles and unknown names yield a readable ERR, not a crash.
|
|
46
|
+
|
|
47
|
+
// Compile a user-typed formula to a JS function that takes the
|
|
48
|
+
// aggregation helpers and returns the scalar result. Throws on parse
|
|
49
|
+
// errors. The compiled function is reusable across re-evaluations
|
|
50
|
+
// (closure over the same composition data).
|
|
51
|
+
function _tpCompile(formula) {
|
|
52
|
+
let src = String(formula || '').trim();
|
|
53
|
+
if (!src) throw new Error('Empty formula');
|
|
54
|
+
|
|
55
|
+
// 1. Rewrite aggregation calls into (condFn, exprFn) form.
|
|
56
|
+
// Iterate because inner aggregations may appear after outer rewrite.
|
|
57
|
+
src = _tpRewriteAggs(src);
|
|
58
|
+
|
|
59
|
+
// 2. Sanitize: whitelist characters to keep eval safe. The compiled
|
|
60
|
+
// function only has SUM/AVG/MIN/MAX/COUNT in scope (no globals,
|
|
61
|
+
// no this, no Function/eval/import), and arrow fns inside
|
|
62
|
+
// aggregations capture `r` as the iteration variable.
|
|
63
|
+
if (!/^[a-zA-Z0-9_\[\]\.\s+\-*/(),<>=!&|?:%'"]+$/.test(src)) {
|
|
64
|
+
throw new Error('Formula contains disallowed characters. Allowed: letters, digits, _ [ ] . ( ) , + - * / < > = ! & | ? : % \' "');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 3. Only the formula language's own names may survive the rewrite (17m).
|
|
68
|
+
// The character check alone let `SUM.constructor('…')()` or
|
|
69
|
+
// `ing['constructor']['constructor']('…')()` through — every character is
|
|
70
|
+
// allowed — and new Function runs in the page's global scope. A parameter
|
|
71
|
+
// is shared data: in a Pro workspace one editor's formula runs in every
|
|
72
|
+
// member's browser, so it could read their session and send it anywhere.
|
|
73
|
+
// Same defence smart-search has had since 2026-05-31: strip string
|
|
74
|
+
// literals (they are data), then every identifier left must be one of
|
|
75
|
+
// ours, and a `[` may only follow `r.ing` (the custom-field lookup, which
|
|
76
|
+
// answers own fields only — _tpIngredientView).
|
|
77
|
+
_tpCheckNames(src);
|
|
78
|
+
|
|
79
|
+
// 4. Compile via Function so we can pass in our aggregation helpers
|
|
80
|
+
// as locals. Any syntax error surfaces with a meaningful message.
|
|
81
|
+
try {
|
|
82
|
+
// eslint-disable-next-line no-new-func
|
|
83
|
+
return new Function('SUM', 'AVG', 'MEAN', 'MIN', 'MAX', 'COUNT', 'PI', 'E', '_tpNum', '_tpHas',
|
|
84
|
+
`"use strict"; return (${src});`);
|
|
85
|
+
} catch (e) {
|
|
86
|
+
throw new Error(`Parse error: ${e.message}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const _TP_SAFE_NAMES = new Set([
|
|
91
|
+
'SUM', 'AVG', 'MEAN', 'MIN', 'MAX', 'COUNT', 'PI', 'E', '_tpNum', '_tpHas',
|
|
92
|
+
'r', 'ing', 'amount', 'unit', 'role', 'null', 'true', 'false', 'undefined',
|
|
93
|
+
'NaN', 'Infinity', // an unknown [Param] is substituted as (NaN)
|
|
94
|
+
]);
|
|
95
|
+
function _tpCheckNames(src) {
|
|
96
|
+
const noStr = String(src).replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, '""');
|
|
97
|
+
// Numbers first (1e3, 2.5E-2) so their exponent isn't read as a name.
|
|
98
|
+
const noNum = noStr.replace(/\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b|\.\d+(?:[eE][+-]?\d+)?\b/g, '0');
|
|
99
|
+
for (const id of (noNum.match(/[A-Za-z_$][A-Za-z0-9_$]*/g) || [])) {
|
|
100
|
+
if (!_TP_SAFE_NAMES.has(id)) {
|
|
101
|
+
throw new Error(`"${id}" isn't part of the formula language. Use SUM / AVG / MIN / MAX / COUNT, row.amount, ing.<field> or ing['Field name'], numbers and [Other parameter].`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// Nothing in the language calls the result of a lookup or a bracket: the
|
|
105
|
+
// only calls are SUM( … ) and the like, by name. `ing['x'](…)` or `(…)(…)`
|
|
106
|
+
// can only be an attempt to call something.
|
|
107
|
+
const called = (noNum.match(/[A-Za-z_$][A-Za-z0-9_$]*(?=\s*\()/g) || []).filter(n => !/^(SUM|AVG|MEAN|MIN|MAX|COUNT|_tpNum|_tpHas)$/.test(n));
|
|
108
|
+
if (called.length || /[\])]\s*\(/.test(noStr)) {
|
|
109
|
+
throw new Error("A value can't be called like a function — only SUM / AVG / MIN / MAX / COUNT take parentheses.");
|
|
110
|
+
}
|
|
111
|
+
for (let i = noStr.indexOf('['); i >= 0; i = noStr.indexOf('[', i + 1)) {
|
|
112
|
+
if (!/\br\s*\.\s*ing\s*$/.test(noStr.slice(0, i))) {
|
|
113
|
+
throw new Error("Square brackets are only for a field name: ing['Field name'] (and [Other parameter]).");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Walk the source string, find each AGG(...) call (with balanced
|
|
119
|
+
// parens), split on WHERE, and replace with AGG(condFn, exprFn).
|
|
120
|
+
// Restart from the start after every rewrite so nested aggs get
|
|
121
|
+
// handled correctly (they'll be the bodies of outer mappers).
|
|
122
|
+
function _tpRewriteAggs(src) {
|
|
123
|
+
const aggs = ['SUM', 'AVG', 'MEAN', 'MIN', 'MAX', 'COUNT'];
|
|
124
|
+
for (let pass = 0; pass < 50; pass++) {
|
|
125
|
+
let earliest = -1, earliestAgg = '', earliestMatchLen = 0;
|
|
126
|
+
for (const agg of aggs) {
|
|
127
|
+
// Scan ALL occurrences of this agg and pick the earliest one
|
|
128
|
+
// that hasn't been rewritten yet (afterOpen not `r =>`/`null`).
|
|
129
|
+
// A naive single re.exec() would stop at the first occurrence
|
|
130
|
+
// even if that one was already rewritten, masking later SUMs.
|
|
131
|
+
const re = new RegExp(`\\b${agg}\\s*\\(`, 'gi');
|
|
132
|
+
let m;
|
|
133
|
+
while ((m = re.exec(src)) !== null) {
|
|
134
|
+
const afterOpen = src.slice(m.index + m[0].length).replace(/^\s*/, '');
|
|
135
|
+
// Rewritten aggs start with `null,` (no cond) or `(r =>` (cond
|
|
136
|
+
// present). Detect both so the pass loop doesn't double-rewrite
|
|
137
|
+
// already-processed aggs into nested garbage.
|
|
138
|
+
if (afterOpen.startsWith('null') || afterOpen.startsWith('(r =>') || afterOpen.startsWith('r =>')) continue;
|
|
139
|
+
if (earliest < 0 || m.index < earliest) {
|
|
140
|
+
earliest = m.index;
|
|
141
|
+
earliestAgg = agg.toUpperCase();
|
|
142
|
+
earliestMatchLen = m[0].length;
|
|
143
|
+
}
|
|
144
|
+
break; // we only need the first un-rewritten match per agg name
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (earliest < 0) return src;
|
|
148
|
+
|
|
149
|
+
const openIdx = earliest + earliestMatchLen - 1;
|
|
150
|
+
let depth = 1, j = openIdx + 1;
|
|
151
|
+
while (j < src.length && depth > 0) {
|
|
152
|
+
if (src[j] === '(') depth++;
|
|
153
|
+
else if (src[j] === ')') depth--;
|
|
154
|
+
if (depth === 0) break;
|
|
155
|
+
j++;
|
|
156
|
+
}
|
|
157
|
+
if (depth !== 0) throw new Error(`Unmatched paren in ${earliestAgg}(...)`);
|
|
158
|
+
const inside = src.slice(openIdx + 1, j);
|
|
159
|
+
|
|
160
|
+
// Split on top-level WHERE (not inside nested parens). Simple scan.
|
|
161
|
+
let whereIdx = -1;
|
|
162
|
+
{
|
|
163
|
+
let d = 0;
|
|
164
|
+
const re = /\bWHERE\b/gi;
|
|
165
|
+
let m;
|
|
166
|
+
while ((m = re.exec(inside)) !== null) {
|
|
167
|
+
// Count parens up to this point
|
|
168
|
+
let depthAt = 0;
|
|
169
|
+
for (let k = 0; k < m.index; k++) {
|
|
170
|
+
if (inside[k] === '(') depthAt++;
|
|
171
|
+
else if (inside[k] === ')') depthAt--;
|
|
172
|
+
}
|
|
173
|
+
if (depthAt === 0) { whereIdx = m.index; d = m[0].length; break; }
|
|
174
|
+
}
|
|
175
|
+
if (whereIdx >= 0) {
|
|
176
|
+
var exprPart = inside.slice(0, whereIdx).trim();
|
|
177
|
+
var condPart = inside.slice(whereIdx + d).trim();
|
|
178
|
+
} else {
|
|
179
|
+
var exprPart = inside.trim();
|
|
180
|
+
var condPart = '';
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const exprBody = exprPart || (earliestAgg === 'COUNT' ? '1' : '0');
|
|
185
|
+
const exprFn = `(r => (${_tpTransformRefs(exprBody)}))`;
|
|
186
|
+
const condFn = condPart ? `(r => (${_tpTransformRefs(condPart)}))` : 'null';
|
|
187
|
+
const replacement = `${earliestAgg}(${condFn}, ${exprFn})`;
|
|
188
|
+
src = src.slice(0, earliest) + replacement + src.slice(j + 1);
|
|
189
|
+
}
|
|
190
|
+
throw new Error('Formula too complex (>50 aggregation rewrites)');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Transform `row.X` and `ing.X` accessors into the iteration variable
|
|
194
|
+
// (`r`) shape used inside arrow-fn bodies. Order matters: handle the
|
|
195
|
+
// bracketed `ing['Name']` form before the dotted form, and the dotted
|
|
196
|
+
// form before bare `ing`.
|
|
197
|
+
function _tpTransformRefs(expr) {
|
|
198
|
+
let s = String(expr);
|
|
199
|
+
// ing.['Name'] → r.ing['Name'] (defensive — some users may type the dot)
|
|
200
|
+
s = s.replace(/\bing\s*\.\s*\[/g, "r.ing[");
|
|
201
|
+
// ing['Name'] → r.ing['Name']
|
|
202
|
+
s = s.replace(/\bing\s*\[/g, "r.ing[");
|
|
203
|
+
// ing.identifier → r.ing['identifier'] (bracket-quote so custom field
|
|
204
|
+
// names with non-identifier chars work via the dotted shortcut)
|
|
205
|
+
s = s.replace(/\bing\s*\.\s*([A-Za-z_][A-Za-z0-9_]*)/g, "r.ing['$1']");
|
|
206
|
+
// bare `ing` (rare; e.g. `ing != null`) → r.ing
|
|
207
|
+
s = s.replace(/\bing\b(?!\s*[\.\[])/g, 'r.ing');
|
|
208
|
+
|
|
209
|
+
// row.weight → r.amount (alias)
|
|
210
|
+
s = s.replace(/\brow\s*\.\s*weight\b/g, 'r.amount');
|
|
211
|
+
// row.identifier → r.identifier
|
|
212
|
+
s = s.replace(/\brow\s*\.\s*([A-Za-z_][A-Za-z0-9_]*)/g, 'r.$1');
|
|
213
|
+
// bare `row` → r
|
|
214
|
+
s = s.replace(/\brow\b(?!\s*\.)/g, 'r');
|
|
215
|
+
|
|
216
|
+
// Numeric-guard relational comparisons against a number literal: wrap the
|
|
217
|
+
// field-ref operand in _tpNum() so a MISSING field (null in the ingredient
|
|
218
|
+
// view) becomes NaN and fails every < <= > >= test, instead of coercing to 0
|
|
219
|
+
// and slipping through — e.g. `WHERE ing.purity < 100` must skip rows that
|
|
220
|
+
// have no purity, not count them. == / != and pure value arithmetic
|
|
221
|
+
// (SUM(row.amount * ing.density)) are deliberately left alone.
|
|
222
|
+
const REF = "(r\\.ing\\[[^\\]]*\\]|r\\.[A-Za-z_][A-Za-z0-9_]*)";
|
|
223
|
+
const NUM = "(-?\\d+(?:\\.\\d+)?)";
|
|
224
|
+
s = s.replace(new RegExp(REF + "\\s*(<=|>=|<|>)\\s*" + NUM, "g"), "_tpNum($1)$2$3");
|
|
225
|
+
s = s.replace(new RegExp(NUM + "\\s*(<=|>=|<|>)\\s*" + REF, "g"), "$1$2_tpNum($3)");
|
|
226
|
+
|
|
227
|
+
// Multi-valued label fields: `ing.function` is pipe-joined ('Film Former|
|
|
228
|
+
// Binder') so plain string equality against one label never matches. Rewrite
|
|
229
|
+
// `ing.function == 'Binder'` (either operand order, == / != / === / !==) into
|
|
230
|
+
// _tpHas(field, 'Binder'), which matches ANY of the pipe-separated labels,
|
|
231
|
+
// trimmed and case-insensitively (labels are vocabulary, not case-sensitive
|
|
232
|
+
// data). Single-valued fields keep exact equality (2026-09-13).
|
|
233
|
+
const MV = "(r\\.ing\\['(?:function|tags)'\\])";
|
|
234
|
+
const LIT = "('(?:[^'\\\\]|\\\\.)*'|\"(?:[^\"\\\\]|\\\\.)*\")";
|
|
235
|
+
const OP = "\\s*(===|!==|==|!=)\\s*";
|
|
236
|
+
const has = (neg, f, l) => (neg ? '!' : '') + '_tpHas(' + f + ',' + l + ')';
|
|
237
|
+
s = s.replace(new RegExp(MV + OP + LIT, "g"), (_, f, op, l) => has(op[0] === '!', f, l));
|
|
238
|
+
s = s.replace(new RegExp(LIT + OP + MV, "g"), (_, l, op, f) => has(op[0] === '!', f, l));
|
|
239
|
+
return s;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Evaluate a compiled formula against a row set. Aggregation helpers
|
|
243
|
+
// are passed in fresh on each call so they close over the right rows.
|
|
244
|
+
function _tpEvaluate(formula, rows) {
|
|
245
|
+
const fn = _tpCompile(formula);
|
|
246
|
+
const safe = (rows || []).filter(Boolean);
|
|
247
|
+
const filteredFor = (cf) => {
|
|
248
|
+
if (!cf) return safe;
|
|
249
|
+
return safe.filter(r => { try { return !!cf(r); } catch { return false; } });
|
|
250
|
+
};
|
|
251
|
+
const mapNumeric = (subset, ef) => {
|
|
252
|
+
const out = [];
|
|
253
|
+
for (const r of subset) {
|
|
254
|
+
let v;
|
|
255
|
+
try { v = ef(r); } catch { continue; }
|
|
256
|
+
if (typeof v === 'number' && Number.isFinite(v)) out.push(v);
|
|
257
|
+
}
|
|
258
|
+
return out;
|
|
259
|
+
};
|
|
260
|
+
const SUM = (cf, ef) => mapNumeric(filteredFor(cf), ef).reduce((a, b) => a + b, 0);
|
|
261
|
+
const AVG = (cf, ef) => { const a = mapNumeric(filteredFor(cf), ef); return a.length ? a.reduce((x, y) => x + y, 0) / a.length : NaN; };
|
|
262
|
+
const MIN = (cf, ef) => { const a = mapNumeric(filteredFor(cf), ef); return a.length ? Math.min(...a) : NaN; };
|
|
263
|
+
const MAX = (cf, ef) => { const a = mapNumeric(filteredFor(cf), ef); return a.length ? Math.max(...a) : NaN; };
|
|
264
|
+
const COUNT = (cf) => filteredFor(cf).length;
|
|
265
|
+
// Numeric coercion for guarded relational comparisons (see _tpTransformRefs):
|
|
266
|
+
// non-numbers → NaN so a missing field never satisfies < <= > >=.
|
|
267
|
+
const _tpNum = (x) => (typeof x === 'number' ? x : (x == null || x === '' ? NaN : Number(x)));
|
|
268
|
+
// Any-of match for pipe-joined label fields (see _tpTransformRefs).
|
|
269
|
+
const _tpHas = (field, lit) => {
|
|
270
|
+
const want = String(lit == null ? '' : lit).trim().toLowerCase();
|
|
271
|
+
const vals = Array.isArray(field) ? field : String(field == null ? '' : field).split('|');
|
|
272
|
+
return vals.some(v => String(v).trim().toLowerCase() === want);
|
|
273
|
+
};
|
|
274
|
+
return fn(SUM, AVG, AVG, MIN, MAX, COUNT, Math.PI, Math.E, _tpNum, _tpHas);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Same evaluation as _tpEvaluate but records a per-aggregate breakdown (each
|
|
278
|
+
// SUM/AVG/MIN/MAX/COUNT call, the rows it spanned, and each row's value) so the
|
|
279
|
+
// "Show calculation" panel can show HOW a computed parameter got its number —
|
|
280
|
+
// the same transparency the Fox-Tg / density panels give. Returns
|
|
281
|
+
// { value, trace:[{op, terms:[{label,value}], result}] }.
|
|
282
|
+
function _tpTrace(formula, rows) {
|
|
283
|
+
let fn;
|
|
284
|
+
try { fn = _tpCompile(formula); } catch (_) { return { value: NaN, trace: [] }; }
|
|
285
|
+
const safe = (rows || []).filter(Boolean);
|
|
286
|
+
const trace = [];
|
|
287
|
+
const rowLabel = r => (r && r.ing && !r.ing._isSubFormula && (r.ing.name || r.ing.uid))
|
|
288
|
+
|| (r && r.ing && r.ing._isSubFormula ? (r._subName || '(sub-formula)') : '(row)');
|
|
289
|
+
const filteredFor = cf => { if (!cf) return safe; return safe.filter(r => { try { return !!cf(r); } catch { return false; } }); };
|
|
290
|
+
const collect = (op, cf, ef) => {
|
|
291
|
+
const subset = filteredFor(cf);
|
|
292
|
+
const terms = subset.map(r => { let v; try { v = ef(r); } catch { v = undefined; } return { idx: safe.indexOf(r), label: rowLabel(r), value: (typeof v === 'number' && Number.isFinite(v)) ? v : null }; });
|
|
293
|
+
return { op, terms };
|
|
294
|
+
};
|
|
295
|
+
const nums = c => c.terms.map(t => t.value).filter(v => v != null);
|
|
296
|
+
const SUM = (cf, ef) => { const c = collect('SUM', cf, ef); const res = nums(c).reduce((a, b) => a + b, 0); trace.push({ ...c, result: res }); return res; };
|
|
297
|
+
const AVG = (cf, ef) => { const c = collect('AVG', cf, ef); const a = nums(c); const res = a.length ? a.reduce((x, y) => x + y, 0) / a.length : NaN; trace.push({ ...c, result: res }); return res; };
|
|
298
|
+
const MIN = (cf, ef) => { const c = collect('MIN', cf, ef); const a = nums(c); const res = a.length ? Math.min(...a) : NaN; trace.push({ ...c, result: res }); return res; };
|
|
299
|
+
const MAX = (cf, ef) => { const c = collect('MAX', cf, ef); const a = nums(c); const res = a.length ? Math.max(...a) : NaN; trace.push({ ...c, result: res }); return res; };
|
|
300
|
+
const COUNT = (cf) => { const subset = filteredFor(cf); trace.push({ op: 'COUNT', terms: subset.map(r => ({ idx: safe.indexOf(r), label: rowLabel(r), value: 1 })), result: subset.length }); return subset.length; };
|
|
301
|
+
const _tpNum = x => (typeof x === 'number' ? x : (x == null || x === '' ? NaN : Number(x)));
|
|
302
|
+
let value = NaN;
|
|
303
|
+
try { value = fn(SUM, AVG, AVG, MIN, MAX, COUNT, Math.PI, Math.E, _tpNum); } catch (_) {}
|
|
304
|
+
return { value, trace };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ---- Cross-parameter references ----
|
|
308
|
+
//
|
|
309
|
+
// A formula may reference another parameter's computed value by name in
|
|
310
|
+
// square brackets, e.g.
|
|
311
|
+
// [Total cost] / [Total weight]
|
|
312
|
+
// This is distinct from the `ing['Custom Field']` accessor: an ingredient
|
|
313
|
+
// field access is always preceded by the `ing` token (with or without a
|
|
314
|
+
// dot), so a bare `[Name]` not preceded by `ing` is a parameter reference.
|
|
315
|
+
// References are resolved to numeric literals BEFORE compilation, so the
|
|
316
|
+
// downstream parser/evaluator is unchanged.
|
|
317
|
+
|
|
318
|
+
// Scan a raw formula for parameter references. Returns
|
|
319
|
+
// [{ name, start, end }] where start/end index the `[` and `]`.
|
|
320
|
+
// Skips `ing[...]` / `ing.[...]` ingredient-field accesses.
|
|
321
|
+
function _tpScanParamRefs(formula) {
|
|
322
|
+
const s = String(formula || '');
|
|
323
|
+
const refs = [];
|
|
324
|
+
for (let i = 0; i < s.length; i++) {
|
|
325
|
+
if (s[i] !== '[') continue;
|
|
326
|
+
// An ingredient access has `ing` (optionally `ing.`) immediately before
|
|
327
|
+
// the bracket — skip those; everything else is a parameter reference.
|
|
328
|
+
if (/(^|[^A-Za-z0-9_])ing\s*\.?\s*$/.test(s.slice(0, i))) continue;
|
|
329
|
+
const close = s.indexOf(']', i + 1);
|
|
330
|
+
if (close < 0) break; // unterminated — let the compiler surface it
|
|
331
|
+
const name = s.slice(i + 1, close).trim().replace(/^['"]|['"]$/g, '').trim();
|
|
332
|
+
refs.push({ name, start: i, end: close });
|
|
333
|
+
i = close;
|
|
334
|
+
}
|
|
335
|
+
return refs;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Replace each `[Param Name]` with its numeric value from valueMap (keyed
|
|
339
|
+
// by lowercased name). Unknown refs become NaN so the result reads ERR
|
|
340
|
+
// rather than silently computing against 0. Wrapped in parens to preserve
|
|
341
|
+
// arithmetic precedence (e.g. negative values).
|
|
342
|
+
function _tpSubstituteParamRefs(formula, valueMap) {
|
|
343
|
+
const refs = _tpScanParamRefs(formula);
|
|
344
|
+
if (!refs.length) return formula;
|
|
345
|
+
const s = String(formula || '');
|
|
346
|
+
let out = '', cursor = 0;
|
|
347
|
+
for (const r of refs) {
|
|
348
|
+
out += s.slice(cursor, r.start);
|
|
349
|
+
const v = valueMap[r.name.toLowerCase()];
|
|
350
|
+
out += '(' + (Number.isFinite(v) ? v : 'NaN') + ')';
|
|
351
|
+
cursor = r.end + 1;
|
|
352
|
+
}
|
|
353
|
+
return out + s.slice(cursor);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Evaluate a whole list of parameters together, resolving cross-references
|
|
357
|
+
// in dependency order. Returns a map { [paramId]: { value, error, name } }.
|
|
358
|
+
// Dependency cycles, unknown references, and non-numeric dependencies each
|
|
359
|
+
// yield a readable error on the affected parameter (mirroring the per-cell
|
|
360
|
+
// ERR rendering) without throwing.
|
|
361
|
+
function _tpEvaluateAll(params, rows) {
|
|
362
|
+
const list = (params || []).filter(p => p && p.id);
|
|
363
|
+
const byName = {};
|
|
364
|
+
list.forEach(p => { if (p.name) byName[String(p.name).trim().toLowerCase()] = p; });
|
|
365
|
+
|
|
366
|
+
const results = {}; // paramId -> { value, error, name }
|
|
367
|
+
const visiting = {}; // paramId -> true while on the resolution stack
|
|
368
|
+
|
|
369
|
+
function resolve(p) {
|
|
370
|
+
if (results[p.id]) return results[p.id];
|
|
371
|
+
if (visiting[p.id]) {
|
|
372
|
+
return (results[p.id] = { value: null, error: 'Circular reference', name: p.name });
|
|
373
|
+
}
|
|
374
|
+
visiting[p.id] = true;
|
|
375
|
+
|
|
376
|
+
const valueMap = {};
|
|
377
|
+
let depError = '';
|
|
378
|
+
for (const ref of _tpScanParamRefs(p.formula)) {
|
|
379
|
+
const key = ref.name.toLowerCase();
|
|
380
|
+
const dep = byName[key];
|
|
381
|
+
if (!dep) { depError = depError || `Unknown parameter [${ref.name}]`; continue; }
|
|
382
|
+
if (dep.id === p.id) { depError = depError || 'Circular reference'; continue; }
|
|
383
|
+
const dr = resolve(dep);
|
|
384
|
+
if (dr.error) { depError = depError || `[${ref.name}] → ${dr.error}`; continue; }
|
|
385
|
+
if (!Number.isFinite(dr.value)) { depError = depError || `[${ref.name}] is not a number`; continue; }
|
|
386
|
+
valueMap[key] = dr.value;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
let res;
|
|
390
|
+
if (depError) {
|
|
391
|
+
res = { value: null, error: depError, name: p.name };
|
|
392
|
+
} else {
|
|
393
|
+
try {
|
|
394
|
+
const substituted = _tpSubstituteParamRefs(p.formula, valueMap);
|
|
395
|
+
res = { value: _tpEvaluate(substituted, rows), error: '', name: p.name };
|
|
396
|
+
} catch (e) {
|
|
397
|
+
res = { value: null, error: e.message || 'eval error', name: p.name };
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
delete visiting[p.id];
|
|
401
|
+
return (results[p.id] = res);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
list.forEach(resolve);
|
|
405
|
+
return results;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Build the row set the evaluator iterates. Sub-formula rows are
|
|
409
|
+
// included (so `SUM(row.amount)` is correct) but their `ing` is empty
|
|
410
|
+
// (so chemistry-aware aggregations naturally skip them). Custom
|
|
411
|
+
// ingredient fields are flattened into `ing.{name}` alongside the
|
|
412
|
+
// standard fields, with numeric coercion when the string parses.
|
|
413
|
+
function _tpBuildRows(composition) {
|
|
414
|
+
const rows = [];
|
|
415
|
+
(composition || []).forEach(c => {
|
|
416
|
+
if (!c) return;
|
|
417
|
+
const amount = parseFloat(c.amount);
|
|
418
|
+
const base = {
|
|
419
|
+
amount: Number.isFinite(amount) ? amount : 0,
|
|
420
|
+
unit: c.unit || '',
|
|
421
|
+
role: c.role || '',
|
|
422
|
+
// As typed on the composition row — the "Show calc" table shows it next
|
|
423
|
+
// to the wt% the expressions actually see. Never read by the math.
|
|
424
|
+
_entered: { amount: c.amount, unit: c.unit || '' },
|
|
425
|
+
};
|
|
426
|
+
if (c.ingredientId) {
|
|
427
|
+
const ing = db.ingredients.find(i => i.id === c.ingredientId);
|
|
428
|
+
// _cfUnits: each custom field's unit label (lower-case name → unit). The
|
|
429
|
+
// math never reads it; _tpMissingInputs uses it to flag mixed units.
|
|
430
|
+
const _cfUnits = {};
|
|
431
|
+
((ing && ing.customFields) || []).forEach(f => { if (f && f.name && f.unit) _cfUnits[String(f.name).trim().toLowerCase()] = String(f.unit).trim(); });
|
|
432
|
+
rows.push({ ...base, ing: _tpIngredientView(ing), _srcIng: ing || null, _cfUnits });
|
|
433
|
+
} else if (c.formulationId) {
|
|
434
|
+
// Sub-formula row: amount/role still meaningful, but no ing.* props.
|
|
435
|
+
// For chemistry-aware aggregations (NV, OHV, PVC), treat as opaque
|
|
436
|
+
// — the user can either flatten the recipe or attach the relevant
|
|
437
|
+
// properties to the sub-formula's parent representation later.
|
|
438
|
+
const sub = db.formulations.find(g => g.id === c.formulationId);
|
|
439
|
+
rows.push({ ...base, ing: { _isSubFormula: true }, _srcSub: c.formulationId, _subName: (sub && sub.name) || 'sub-formula' });
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
// ABSOLUTE rows (a batch's as-prepared composition: 215.9 g water, 6.6 mL
|
|
443
|
+
// sucrose…) are put on the SAME basis the formula's expressions were written
|
|
444
|
+
// for — wt% of the total logged mass, summing to 100 — before any expression
|
|
445
|
+
// sees them. Theoretical parameters are intensive (g/100 g, kcal/100 g,
|
|
446
|
+
// USD/kg), and every stock expression assumes Σ row.amount = 100, so raw
|
|
447
|
+
// grams (or worse, raw mL) reported the shake's protein as 16.8 "g/100 g" on
|
|
448
|
+
// a 250 mL batch (2026-09-13). Volume rows convert via the ROW's own density
|
|
449
|
+
// (sub-formula: its planning density) — never the formula's. Percent rows,
|
|
450
|
+
// and any set where one absolute row can't resolve, are left untouched.
|
|
451
|
+
const isM = u => (typeof isMassUnit === 'function') && isMassUnit(u);
|
|
452
|
+
const isV = u => (typeof isVolUnit === 'function') && isVolUnit(u);
|
|
453
|
+
const abs = rows.filter(r => isM(r.unit) || isV(r.unit));
|
|
454
|
+
if (abs.length && typeof massToKg === 'function' && typeof volToL === 'function') {
|
|
455
|
+
const grams = abs.map(r => {
|
|
456
|
+
if (isM(r.unit)) { const f = massToKg(r.unit); return (f != null) ? r.amount * f * 1000 : null; }
|
|
457
|
+
const L = volToL(r.unit);
|
|
458
|
+
let d = null;
|
|
459
|
+
if (r._srcIng && typeof _densityToKgPerL === 'function') d = _densityToKgPerL(r._srcIng.density, r._srcIng.densityUnit);
|
|
460
|
+
else if (r._srcSub && typeof _rolledUpDensity === 'function') { const sub = db.formulations.find(g => g.id === r._srcSub); d = sub ? _rolledUpDensity(sub) : null; }
|
|
461
|
+
return (L != null && d != null && isFinite(d) && d > 0) ? r.amount * L * d * 1000 : null;
|
|
462
|
+
});
|
|
463
|
+
const total = grams.reduce((a, g) => a + (g || 0), 0);
|
|
464
|
+
if (grams.every(g => g != null && isFinite(g)) && total > 0) {
|
|
465
|
+
abs.forEach((r, i) => { r.amount = (grams[i] / total) * 100; r.unit = 'wt%'; });
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
rows.forEach(r => { delete r._srcIng; delete r._srcSub; });
|
|
469
|
+
return rows;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Project an ingredient onto the flat `ing.X` namespace the formula
|
|
473
|
+
// language uses. Standard fields are exposed under their canonical
|
|
474
|
+
// names; custom fields are merged in by name with numeric coercion
|
|
475
|
+
// when the value parses as a number.
|
|
476
|
+
//
|
|
477
|
+
// Lookups are case-insensitive: a formula written `ing.Solids` resolves
|
|
478
|
+
// to the same field as `ing.solids` and `ing['SOLIDS']`. This matches
|
|
479
|
+
// the expectation set by Excel cell references, ChemDraw / RDKit
|
|
480
|
+
// property names, and pretty much every spreadsheet-like tool a lab
|
|
481
|
+
// user has touched. Case-sensitivity here was producing silent zeros
|
|
482
|
+
// on harmless typos and was the wrong default.
|
|
483
|
+
function _tpIngredientView(ing) {
|
|
484
|
+
if (!ing) return { _missing: true };
|
|
485
|
+
const out = {
|
|
486
|
+
uid: ing.uid || '',
|
|
487
|
+
name: ing.name || '',
|
|
488
|
+
chemicalName: ing.chemicalName || '',
|
|
489
|
+
chemical: ing.chemicalName || '', // alias: the column reads "Chemical Name"
|
|
490
|
+
cas: ing.cas || '',
|
|
491
|
+
grade: ing.grade || '',
|
|
492
|
+
supplier: ing.supplier || '',
|
|
493
|
+
catalog: ing.catalog || '',
|
|
494
|
+
state: ing.state || '',
|
|
495
|
+
function: ing.function || '',
|
|
496
|
+
storage: ing.storage || '',
|
|
497
|
+
// Tier 1 expansion fields — flatten nested sub-objects so the
|
|
498
|
+
// predicate language can reach them without dotted access:
|
|
499
|
+
// class == 'sequence' AND sequence contains 'KTTKS'
|
|
500
|
+
// taxon contains 'Centella' OR taxonId == 13954
|
|
501
|
+
class: ing.ingredientClass || '',
|
|
502
|
+
inchiKey: ing.inchiKey || '',
|
|
503
|
+
sequence: ing.sequence?.raw || '',
|
|
504
|
+
sequenceType: ing.sequence?.type || '',
|
|
505
|
+
taxon: ing.taxon?.scientificName || '',
|
|
506
|
+
taxonId: ing.taxon?.ncbiId != null ? Number(ing.taxon.ncbiId) : null,
|
|
507
|
+
};
|
|
508
|
+
// Numeric standard fields — coerce defensively
|
|
509
|
+
const num = (v) => {
|
|
510
|
+
const n = typeof v === 'number' ? v : (v != null && !isNaN(parseFloat(v)) ? parseFloat(v) : null);
|
|
511
|
+
return n;
|
|
512
|
+
};
|
|
513
|
+
out.density = num(ing.density);
|
|
514
|
+
out.mw = num(ing.mw);
|
|
515
|
+
out.cost = num(ing.cost && typeof ing.cost === 'object' ? ing.cost.value : ing.cost);
|
|
516
|
+
out.purity = num(ing.purity); // % active — filterable (purity < 90) + usable in theo formulas (ing.purity)
|
|
517
|
+
out.stock = num(ing.stockOnHand); // on-hand — filterable (stock < 100) [also on the Inventory view]
|
|
518
|
+
out.reorder = num(ing.reorderThreshold); // reorder point — filterable (stock <= reorder)
|
|
519
|
+
out.tg = (typeof _ingTg === 'function') ? _ingTg(ing) : num(ing.tg); // glass transition °C (any class) — filterable (tg < 50) + usable in theo formulas (ing.tg)
|
|
520
|
+
out.hlb = (typeof _ingHlb === 'function') ? _ingHlb(ing) : num(ing.surfactantDetails ? ing.surfactantDetails.hlb : ing.hlb); // HLB (surfactants) — filterable (hlb < 10) + usable in theo formulas (ing.hlb), e.g. Blend HLB preset
|
|
521
|
+
(ing.customFields || []).forEach(cf => {
|
|
522
|
+
if (!cf || !cf.name) return;
|
|
523
|
+
const raw = cf.value;
|
|
524
|
+
const n = num(raw);
|
|
525
|
+
if (cf.name === '__proto__') return; // would set the prototype, not a field
|
|
526
|
+
out[cf.name] = (n !== null && raw !== null && raw !== '') ? n : raw;
|
|
527
|
+
});
|
|
528
|
+
// Wrap in a Proxy that does case-insensitive property reads. Bracket
|
|
529
|
+
// form `ing['Solids']` and dotted form `ing.Solids` both flow through
|
|
530
|
+
// here, so a single hook covers every access path the formula language
|
|
531
|
+
// emits. Iteration (Object.keys, JSON.stringify) still sees the
|
|
532
|
+
// original-cased keys — only get/has are normalized.
|
|
533
|
+
const lowerIndex = {};
|
|
534
|
+
Object.keys(out).forEach(k => { lowerIndex[k.toLowerCase()] = k; });
|
|
535
|
+
// OWN fields only (17m): `prop in target` also answered for what every object
|
|
536
|
+
// inherits, so ing['constructor'] handed a formula the Object constructor —
|
|
537
|
+
// and from there Function, i.e. any code. A field is data; nothing else is
|
|
538
|
+
// reachable through ing.
|
|
539
|
+
const own = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
|
|
540
|
+
return new Proxy(out, {
|
|
541
|
+
get(target, prop) {
|
|
542
|
+
if (typeof prop !== 'string') return undefined;
|
|
543
|
+
if (own(target, prop)) return target[prop];
|
|
544
|
+
const canon = own(lowerIndex, prop.toLowerCase()) ? lowerIndex[prop.toLowerCase()] : null;
|
|
545
|
+
return canon != null ? target[canon] : undefined;
|
|
546
|
+
},
|
|
547
|
+
has(target, prop) {
|
|
548
|
+
if (typeof prop !== 'string') return false;
|
|
549
|
+
return own(target, prop) || own(lowerIndex, prop.toLowerCase());
|
|
550
|
+
},
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Resolve the effective parameter list for a batch. Batches inherit
|
|
555
|
+
// from formula unless overridden. Mirrors the Procedure pattern
|
|
556
|
+
// (batch.stepsOverride wins when present).
|
|
557
|
+
function _tpEffectiveParams(entityType, entity) {
|
|
558
|
+
if (!entity) return [];
|
|
559
|
+
if (entityType === 'batch') {
|
|
560
|
+
if (Array.isArray(entity.theoreticalParamsOverride)) return entity.theoreticalParamsOverride;
|
|
561
|
+
const f = db.formulations.find(x => x.id === entity.formulationId);
|
|
562
|
+
return (f && Array.isArray(f.theoreticalParameters)) ? f.theoreticalParameters : [];
|
|
563
|
+
}
|
|
564
|
+
// formulation
|
|
565
|
+
return Array.isArray(entity.theoreticalParameters) ? entity.theoreticalParameters : [];
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// Resolve the composition rows for evaluation. For a batch with a
|
|
569
|
+
// non-empty actualComposition, that's the source of truth (the actual
|
|
570
|
+
// mix the lab made); otherwise fall back to the parent formula's
|
|
571
|
+
// proposal. For a formulation, always its own composition.
|
|
572
|
+
function _tpEffectiveComposition(entityType, entity) {
|
|
573
|
+
if (entityType === 'batch') {
|
|
574
|
+
if (Array.isArray(entity.actualComposition) && entity.actualComposition.length) {
|
|
575
|
+
return { source: 'actual', rows: entity.actualComposition };
|
|
576
|
+
}
|
|
577
|
+
const f = db.formulations.find(x => x.id === entity.formulationId);
|
|
578
|
+
return { source: 'proposal', rows: f ? (f.composition || []) : [] };
|
|
579
|
+
}
|
|
580
|
+
return { source: 'proposal', rows: entity.composition || [] };
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// ---- CRUD ----
|
|
584
|
+
function addTheoreticalParam(entityType, entityId, paramData) {
|
|
585
|
+
if (entityType === 'formulation') {
|
|
586
|
+
const f = db.formulations.find(x => x.id === entityId);
|
|
587
|
+
if (!f) return null;
|
|
588
|
+
const list = Array.isArray(f.theoreticalParameters) ? [...f.theoreticalParameters] : [];
|
|
589
|
+
list.push({ id: uid(), ...paramData });
|
|
590
|
+
updateFormulation(f.id, { theoreticalParameters: list });
|
|
591
|
+
return list[list.length - 1];
|
|
592
|
+
}
|
|
593
|
+
if (entityType === 'batch') {
|
|
594
|
+
const b = getBatch(entityId);
|
|
595
|
+
if (!b) return null;
|
|
596
|
+
const list = Array.isArray(b.theoreticalParamsOverride)
|
|
597
|
+
? [...b.theoreticalParamsOverride]
|
|
598
|
+
: [..._tpEffectiveParams('batch', b)]; // start the override from inherited list
|
|
599
|
+
list.push({ id: uid(), ...paramData });
|
|
600
|
+
updateBatch(b.id, { theoreticalParamsOverride: list });
|
|
601
|
+
return list[list.length - 1];
|
|
602
|
+
}
|
|
603
|
+
return null;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function updateTheoreticalParam(entityType, entityId, paramId, paramData) {
|
|
607
|
+
if (entityType === 'formulation') {
|
|
608
|
+
const f = db.formulations.find(x => x.id === entityId);
|
|
609
|
+
if (!f) return null;
|
|
610
|
+
const list = (f.theoreticalParameters || []).map(p =>
|
|
611
|
+
p.id === paramId ? { ...p, ...paramData } : p);
|
|
612
|
+
updateFormulation(f.id, { theoreticalParameters: list });
|
|
613
|
+
return list.find(p => p.id === paramId);
|
|
614
|
+
}
|
|
615
|
+
if (entityType === 'batch') {
|
|
616
|
+
const b = getBatch(entityId);
|
|
617
|
+
if (!b) return null;
|
|
618
|
+
const base = Array.isArray(b.theoreticalParamsOverride)
|
|
619
|
+
? b.theoreticalParamsOverride
|
|
620
|
+
: _tpEffectiveParams('batch', b);
|
|
621
|
+
const list = base.map(p => p.id === paramId ? { ...p, ...paramData } : p);
|
|
622
|
+
updateBatch(b.id, { theoreticalParamsOverride: list });
|
|
623
|
+
return list.find(p => p.id === paramId);
|
|
624
|
+
}
|
|
625
|
+
return null;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function deleteTheoreticalParam(entityType, entityId, paramId) {
|
|
629
|
+
if (entityType === 'formulation') {
|
|
630
|
+
const f = db.formulations.find(x => x.id === entityId);
|
|
631
|
+
if (!f) return;
|
|
632
|
+
const list = (f.theoreticalParameters || []).filter(p => p.id !== paramId);
|
|
633
|
+
updateFormulation(f.id, { theoreticalParameters: list });
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
if (entityType === 'batch') {
|
|
637
|
+
const b = getBatch(entityId);
|
|
638
|
+
if (!b) return;
|
|
639
|
+
const base = Array.isArray(b.theoreticalParamsOverride)
|
|
640
|
+
? b.theoreticalParamsOverride
|
|
641
|
+
: _tpEffectiveParams('batch', b);
|
|
642
|
+
const list = base.filter(p => p.id !== paramId);
|
|
643
|
+
updateBatch(b.id, { theoreticalParamsOverride: list });
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Modal-based confirm wrapper for the per-row delete button (replaces an
|
|
648
|
+
// inline confirm() in the onclick, which the embedded webview suppresses).
|
|
649
|
+
function confirmDeleteTheoreticalParam(entityType, entityId, pId, pName) {
|
|
650
|
+
confirmDataAction({
|
|
651
|
+
title: 'Delete parameter',
|
|
652
|
+
message: `Delete parameter "${esc(pName || '')}"?`,
|
|
653
|
+
confirmLabel: 'Delete',
|
|
654
|
+
danger: true,
|
|
655
|
+
onConfirm: () => {
|
|
656
|
+
deleteTheoreticalParam(entityType, entityId, pId);
|
|
657
|
+
if (entityType === 'batch') renderBatchDetail(entityId);
|
|
658
|
+
else renderFormulationDetail(entityId);
|
|
659
|
+
}
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function resetBatchTheoreticalParams(batchId, _confirmed) {
|
|
664
|
+
if (!_confirmed) { confirmDataAction({ title: 'Reset parameters', message: "Discard this batch's parameter overrides and inherit the formula's parameters again?", confirmLabel: 'Reset', danger: true, onConfirm: () => resetBatchTheoreticalParams(batchId, true) }); return; }
|
|
665
|
+
const b = getBatch(batchId);
|
|
666
|
+
if (!b) return;
|
|
667
|
+
// Use direct write to clear the field (updateBatch merges, doesn't delete)
|
|
668
|
+
const i = db.batches.findIndex(x => x.id === batchId);
|
|
669
|
+
if (i >= 0) {
|
|
670
|
+
const next = { ...db.batches[i] };
|
|
671
|
+
delete next.theoreticalParamsOverride;
|
|
672
|
+
if (typeof now === 'function') next.updatedAt = now();
|
|
673
|
+
db.batches[i] = next;
|
|
674
|
+
saveDB();
|
|
675
|
+
if (typeof _proHook === 'function') _proHook('batch', 'update', db.batches[i]);
|
|
676
|
+
if (typeof _audit === 'function') _audit('update', 'batch', batchId, 'Reset theoretical parameters to formula default');
|
|
677
|
+
}
|
|
678
|
+
if (typeof renderBatchDetail === 'function') renderBatchDetail(batchId);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// ---- Card render ----
|
|
682
|
+
//
|
|
683
|
+
// Used in two contexts:
|
|
684
|
+
// entityType='formulation' → standalone tab content in formulation detail
|
|
685
|
+
// entityType='batch' → card on batch detail (with inherit/override
|
|
686
|
+
// chrome and "compute against actual" indicator)
|
|
687
|
+
// Toggle a param's "Show calculation" expand row.
|
|
688
|
+
function _tpToggleCalc(id) {
|
|
689
|
+
const el = document.getElementById('tp-calc-' + id);
|
|
690
|
+
if (el) el.style.display = (el.style.display === 'none' || !el.style.display) ? 'table-row' : 'none';
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// The expandable "how this number was computed" panel for one parameter
|
|
694
|
+
// (rebuilt 17k — Jun, 2026-10-06: "the calculation table is a bit hard to
|
|
695
|
+
// understand and read"). One table, a row per composition row:
|
|
696
|
+
// Ingredient · As entered · wt% (what row.amount is, after grams / mL are
|
|
697
|
+
// put on a per-100 basis) · each ing.X the formula reads, with its unit ·
|
|
698
|
+
// one column per aggregate (A, B, …) with that row's term
|
|
699
|
+
// then a totals row, and the result spelled out: A / B = 883.45 / 100 = 8.83.
|
|
700
|
+
// A row with no value for a field says "not counted" in that column; a row a
|
|
701
|
+
// WHERE filters out shows a dot. [Param] references are listed with their value.
|
|
702
|
+
const _TP_AGG_RE = /\b(SUM|AVG|MEAN|MIN|MAX|COUNT)\s*\(/gi;
|
|
703
|
+
// Top-level aggregate calls in a formula, left to right: [{ start, end, text }].
|
|
704
|
+
function _tpAggSpans(formula) {
|
|
705
|
+
const s = String(formula || '');
|
|
706
|
+
const out = [];
|
|
707
|
+
let i = 0;
|
|
708
|
+
while (i < s.length) {
|
|
709
|
+
_TP_AGG_RE.lastIndex = i;
|
|
710
|
+
const m = _TP_AGG_RE.exec(s);
|
|
711
|
+
if (!m) break;
|
|
712
|
+
let depth = 0, j = m.index + m[0].length - 1;
|
|
713
|
+
for (; j < s.length; j++) {
|
|
714
|
+
if (s[j] === '(') depth++;
|
|
715
|
+
else if (s[j] === ')' && --depth === 0) break;
|
|
716
|
+
}
|
|
717
|
+
if (j >= s.length) break;
|
|
718
|
+
out.push({ start: m.index, end: j + 1, text: s.slice(m.index, j + 1) });
|
|
719
|
+
i = j + 1;
|
|
720
|
+
}
|
|
721
|
+
return out;
|
|
722
|
+
}
|
|
723
|
+
function _tpReplaceSpans(formula, spans, repl) {
|
|
724
|
+
let out = '', cur = 0;
|
|
725
|
+
spans.forEach((sp, k) => { out += formula.slice(cur, sp.start) + repl(k); cur = sp.end; });
|
|
726
|
+
return out + formula.slice(cur);
|
|
727
|
+
}
|
|
728
|
+
function _tpCalcHtml(p, evalRows, valueMap) {
|
|
729
|
+
const formula = String(p.formula || '');
|
|
730
|
+
const refs = (typeof _tpScanParamRefs === 'function') ? _tpScanParamRefs(formula) : [];
|
|
731
|
+
const seenRef = new Set();
|
|
732
|
+
const refBits = [];
|
|
733
|
+
refs.forEach(rf => {
|
|
734
|
+
const k = rf.name.toLowerCase();
|
|
735
|
+
if (seenRef.has(k)) return; seenRef.add(k);
|
|
736
|
+
const v = valueMap[k];
|
|
737
|
+
refBits.push(`<code>[${esc(rf.name)}]</code> = <strong>${Number.isFinite(v) ? _tpFmtValue(v) : '<span style="color:var(--danger)">ERR</span>'}</strong>`);
|
|
738
|
+
});
|
|
739
|
+
const substituted = (typeof _tpSubstituteParamRefs === 'function') ? _tpSubstituteParamRefs(formula, valueMap) : formula;
|
|
740
|
+
let t = { value: NaN, trace: [] };
|
|
741
|
+
try { t = _tpTrace(substituted, evalRows); } catch (_) {}
|
|
742
|
+
const aggs = (t.trace || []);
|
|
743
|
+
const L = k => String.fromCharCode(65 + k); // A, B, C…
|
|
744
|
+
const mono = 'font-family:ui-monospace,monospace';
|
|
745
|
+
const num = v => (v == null || !Number.isFinite(v)) ? '' : _tpFmtValue(v);
|
|
746
|
+
|
|
747
|
+
// The result line: the formula with each aggregate named, then its numbers.
|
|
748
|
+
const spansOrig = _tpAggSpans(formula), spansSub = _tpAggSpans(substituted);
|
|
749
|
+
let resultHtml = '';
|
|
750
|
+
if (Number.isFinite(t.value)) {
|
|
751
|
+
const unit = p.unit ? ` <span style="color:var(--text-muted)">${esc(p.unit)}</span>` : '';
|
|
752
|
+
if (aggs.length && spansOrig.length === aggs.length && spansSub.length === aggs.length) {
|
|
753
|
+
const sym = _tpReplaceSpans(formula, spansOrig, L).replace(/\s+/g, ' ').trim();
|
|
754
|
+
const nums = _tpReplaceSpans(substituted, spansSub, k => num(aggs[k].result)).replace(/\s+/g, ' ').trim();
|
|
755
|
+
resultHtml = `<div style="${mono};font-size:12.5px;margin-top:8px;color:#0f172a">Value = ${esc(sym)}${sym === nums ? '' : ` = ${esc(nums)}`} = <strong>${esc(_tpFmtValue(t.value))}</strong>${unit}</div>`;
|
|
756
|
+
} else if (!aggs.length && refs.length) {
|
|
757
|
+
const nums = substituted.replace(/\((-?[\d.e+-]+)\)/gi, (m, v) => _tpFmtValue(+v)).replace(/\s+/g, ' ').trim();
|
|
758
|
+
resultHtml = `<div style="${mono};font-size:12.5px;margin-top:8px;color:#0f172a">Value = ${esc(formula.replace(/\s+/g, ' ').trim())} = ${esc(nums)} = <strong>${esc(_tpFmtValue(t.value))}</strong>${unit}</div>`;
|
|
759
|
+
} else {
|
|
760
|
+
resultHtml = `<div style="font-size:12.5px;margin-top:8px">Value = <strong>${esc(_tpFmtValue(t.value))}</strong>${unit}</div>`;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// The table: only when the formula aggregates over rows.
|
|
765
|
+
let tableHtml = '';
|
|
766
|
+
if (aggs.length && aggs.length <= 6 && aggs.every(a => (a.terms || []).every(tm => Number.isInteger(tm.idx)))) {
|
|
767
|
+
const usesAmount = /\brow\.amount\b/.test(formula);
|
|
768
|
+
// "As entered" only says something when a row was typed in g / mL / …;
|
|
769
|
+
// rows already in % are the wt% column itself.
|
|
770
|
+
const showEntered = (evalRows || []).some(r => r && r._entered && !/^(wt%|%|w\/w%?)$/i.test(String(r._entered.unit || '').trim()));
|
|
771
|
+
const fields = [...new Set(_tpFieldRefs(formula))].filter(f => !_TP_TEXT_FIELDS.has(f)).slice(0, 4);
|
|
772
|
+
const units = {};
|
|
773
|
+
fields.forEach(f => { const r = (evalRows || []).find(x => x && x._cfUnits && x._cfUnits[f]); units[f] = r ? r._cfUnits[f] : ''; });
|
|
774
|
+
const byAgg = aggs.map(a => { const m = new Map(); (a.terms || []).forEach(tm => m.set(tm.idx, tm.value)); return m; });
|
|
775
|
+
const th = (txt, title, right) => `<th${title ? ` title="${esc(title)}"` : ''} style="text-align:${right ? 'right' : 'left'};padding:4px 10px;font-size:10.5px;font-weight:600;color:#64748b;text-transform:uppercase;letter-spacing:.04em;border-bottom:1px solid #cbd5e1;white-space:nowrap">${txt}</th>`;
|
|
776
|
+
const td = (html, extra) => `<td style="padding:4px 10px;text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap;${extra || ''}">${html}</td>`;
|
|
777
|
+
const muted = 'color:#94a3b8';
|
|
778
|
+
const head = th('Ingredient') + (showEntered ? th('As entered', 'Amount and unit as typed on the composition row', true) : '')
|
|
779
|
+
+ (usesAmount ? th('wt%', 'row.amount — grams, kg and mL are put on a per-100 basis (volume through each ingredient\'s density) before the formula runs', true) : '')
|
|
780
|
+
+ fields.map(f => th(esc(f) + (units[f] ? ` <span style="text-transform:none;font-weight:400">(${esc(units[f])})</span>` : ''), 'ing.' + f + ' — the bare number; the unit is a label only', true)).join('')
|
|
781
|
+
+ aggs.map((a, k) => th(`<span style="${mono};text-transform:none;color:#1e40af">${L(k)}</span>`, (spansOrig[k] && spansOrig[k].text) || a.op, true)).join('');
|
|
782
|
+
// Banded rows (Jun, 2026-10-06): white / slate-100 on the panel's slate-50,
|
|
783
|
+
// so a row reads across a wide table without a ruler.
|
|
784
|
+
let band = 0;
|
|
785
|
+
const body = (evalRows || []).map((r, i) => {
|
|
786
|
+
if (!r) return '';
|
|
787
|
+
const bg = (band++ % 2) ? '#f1f5f9' : '#ffffff';
|
|
788
|
+
const isSub = r.ing && r.ing._isSubFormula;
|
|
789
|
+
const name = isSub ? `${esc(r._subName || 'sub-formula')} <span style="${muted}">(sub-formula)</span>` : esc((r.ing && (r.ing.name || r.ing.uid)) || '(row)');
|
|
790
|
+
const e = r._entered || {};
|
|
791
|
+
const entered = (e.amount != null && e.amount !== '') ? `${esc(String(e.amount))} ${esc(e.unit || '')}` : '';
|
|
792
|
+
const cells = [];
|
|
793
|
+
cells.push(`<td style="padding:4px 10px;white-space:nowrap;max-width:220px;overflow:hidden;text-overflow:ellipsis" title="${esc((r.ing && r.ing.name) || r._subName || '')}">${name}</td>`);
|
|
794
|
+
if (showEntered) cells.push(td(entered, muted));
|
|
795
|
+
if (usesAmount) cells.push(td(num(r.amount)));
|
|
796
|
+
fields.forEach(f => {
|
|
797
|
+
const v = isSub ? undefined : (r.ing && r.ing[f]);
|
|
798
|
+
cells.push(typeof v === 'number' && Number.isFinite(v) ? td(num(v)) : td('missing', 'color:#b45309;font-style:italic'));
|
|
799
|
+
});
|
|
800
|
+
byAgg.forEach((m, k) => {
|
|
801
|
+
if (!m.has(i)) { cells.push(td('·', muted + `" title="Left out by the WHERE in ${L(k)}`)); return; }
|
|
802
|
+
const v = m.get(i);
|
|
803
|
+
cells.push(v == null ? td('not counted', 'color:#b45309;font-style:italic') : td(num(v)));
|
|
804
|
+
});
|
|
805
|
+
return `<tr class="tp-calc-row" style="background:${bg}">${cells.join('')}</tr>`;
|
|
806
|
+
}).join('');
|
|
807
|
+
const nLead = 1 + (showEntered ? 1 : 0) + (usesAmount ? 1 : 0) + fields.length;
|
|
808
|
+
const opWord = { SUM: 'Sum', AVG: 'Average', MIN: 'Min', MAX: 'Max', COUNT: 'Count' };
|
|
809
|
+
const foot = `<tr style="border-top:1px solid #cbd5e1">
|
|
810
|
+
<td colspan="${nLead}" style="padding:5px 10px;font-weight:600">Total</td>
|
|
811
|
+
${aggs.map((a, k) => td(`<span style="${muted};font-weight:400">${opWord[a.op] || a.op}</span> <strong>${num(a.result)}</strong>`)).join('')}
|
|
812
|
+
</tr>`;
|
|
813
|
+
const legend = aggs.map((a, k) => `<span style="white-space:nowrap"><span style="${mono};color:#1e40af;font-weight:600">${L(k)}</span> = <code style="${mono}">${esc((spansOrig[k] && spansOrig[k].text) || a.op)}</code></span>`).join(' · ');
|
|
814
|
+
tableHtml = `<div style="font-size:11.5px;color:#475569;margin:2px 0 6px;line-height:1.7">${legend}</div>
|
|
815
|
+
<table style="border-collapse:collapse;font-size:12px;min-width:60%"><thead><tr>${head}</tr></thead><tbody>${body}</tbody><tfoot>${foot}</tfoot></table>`;
|
|
816
|
+
} else if (aggs.length) {
|
|
817
|
+
// Many aggregates, or rows we can't line up: the plain per-aggregate lists.
|
|
818
|
+
tableHtml = aggs.filter(a => a.terms && a.terms.length).map((a, k) => `<div style="margin-top:8px">
|
|
819
|
+
<div style="font-size:11px;color:var(--text-muted);margin-bottom:3px">${L(k)} · ${a.op} over ${a.terms.length} row${a.terms.length === 1 ? '' : 's'} = <strong>${num(a.result)}</strong></div>
|
|
820
|
+
<table style="font-size:11.5px;border-collapse:collapse">${a.terms.map(term => `<tr><td style="padding:1px 12px 1px 0;white-space:nowrap">${esc(term.label)}</td><td style="text-align:right;font-variant-numeric:tabular-nums">${term.value == null ? 'not counted' : num(term.value)}</td></tr>`).join('')}</table>
|
|
821
|
+
</div>`).join('');
|
|
822
|
+
}
|
|
823
|
+
return `<div style="padding:12px 14px;background:#f8fafc;border-top:1px solid #e2e8f0;font-size:12px;color:var(--text);overflow-x:auto">
|
|
824
|
+
${refBits.length ? `<div style="font-size:11.5px;color:var(--text-muted);margin-bottom:6px">References: ${refBits.join(' · ')}</div>` : ''}
|
|
825
|
+
${tableHtml || (refBits.length ? '' : '<div style="color:var(--text-muted);font-size:11px">Value comes straight from the expression (no row aggregation).</div>')}
|
|
826
|
+
${resultHtml}
|
|
827
|
+
</div>`;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// Ingredient values a parameter needs that some rows don't have (17d). A row
|
|
831
|
+
// whose ing.X is missing or not a number drops out of SUM / AVG / MIN / MAX
|
|
832
|
+
// without a word, so an energy figure read low with nothing to say why (one
|
|
833
|
+
// missing ing.fat drops that row's protein and carbs too). Fields read inside
|
|
834
|
+
// a WHERE are skipped: there a missing value is the filter's job (Blend HLB
|
|
835
|
+
// counts only the rows that carry an HLB). Sub-formula rows carry no ing.*
|
|
836
|
+
// values at all and are reported on their own. Rows with amount 0 add nothing
|
|
837
|
+
// either way and are left out.
|
|
838
|
+
const _TP_TEXT_FIELDS = new Set(['uid', 'name', 'chemicalname', 'chemical', 'cas', 'grade', 'supplier', 'catalog', 'state', 'function', 'storage', 'class', 'inchikey', 'sequence', 'sequencetype', 'taxon']);
|
|
839
|
+
function _tpFieldRefs(str) {
|
|
840
|
+
const out = [];
|
|
841
|
+
const s = String(str || '');
|
|
842
|
+
s.replace(/\bing\.([A-Za-z_$][\w$]*)/g, (m, n) => { out.push(n.toLowerCase()); return m; });
|
|
843
|
+
s.replace(/\bing\[\s*(['"])(.*?)\1\s*\]/g, (m, q, n) => { out.push(String(n).toLowerCase()); return m; });
|
|
844
|
+
return out;
|
|
845
|
+
}
|
|
846
|
+
function _tpMissingInputs(formula, rows) {
|
|
847
|
+
const guarded = new Set();
|
|
848
|
+
const outside = String(formula || '').replace(/\bWHERE\b[^)]*/gi, m => { _tpFieldRefs(m).forEach(f => guarded.add(f)); return ' '; });
|
|
849
|
+
const fields = [...new Set(_tpFieldRefs(outside))].filter(f => !guarded.has(f) && !_TP_TEXT_FIELDS.has(f));
|
|
850
|
+
const res = { fields: [], subs: [], ingredients: [], mixed: [] };
|
|
851
|
+
if (!fields.length) return res;
|
|
852
|
+
const byField = {};
|
|
853
|
+
const names = new Set();
|
|
854
|
+
const unitsBy = {}; // field → { unit: [ingredient names] }, rows that have a value
|
|
855
|
+
(rows || []).forEach(r => {
|
|
856
|
+
if (!r || !r.ing || !(r.amount > 0 || r.amount < 0)) return;
|
|
857
|
+
if (r.ing._isSubFormula) { res.subs.push(r._subName || 'sub-formula'); return; }
|
|
858
|
+
const nm = r.ing._missing ? '(deleted ingredient)' : (r.ing.name || '(unnamed)');
|
|
859
|
+
fields.forEach(f => {
|
|
860
|
+
const v = r.ing._missing ? undefined : r.ing[f];
|
|
861
|
+
if (typeof v === 'number' && Number.isFinite(v)) {
|
|
862
|
+
const u = r._cfUnits && r._cfUnits[f];
|
|
863
|
+
if (u) ((unitsBy[f] = unitsBy[f] || {})[u] = unitsBy[f][u] || []).push(nm);
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
(byField[f] = byField[f] || []).push(nm);
|
|
867
|
+
names.add(nm);
|
|
868
|
+
});
|
|
869
|
+
});
|
|
870
|
+
res.fields = fields.filter(f => byField[f]).map(f => ({ field: f, names: byField[f] }));
|
|
871
|
+
// Mixed units: the sum reads bare numbers, so 500 mg/100g of sodium next to
|
|
872
|
+
// 0.4 g/100g is off by 1000× without a word. Flag a field whose ingredients
|
|
873
|
+
// carry two or more different unit labels; rows with no unit are not counted
|
|
874
|
+
// (most workspaces never set one, and that is not a conflict).
|
|
875
|
+
res.mixed = fields.filter(f => unitsBy[f] && Object.keys(unitsBy[f]).length > 1)
|
|
876
|
+
.map(f => ({ field: f, units: Object.entries(unitsBy[f]).sort((a, b) => b[1].length - a[1].length).map(([unit, names]) => ({ unit, names })) }));
|
|
877
|
+
res.ingredients = [...names];
|
|
878
|
+
return res;
|
|
879
|
+
}
|
|
880
|
+
// One short line for the table (and its tooltip), '' when nothing is missing.
|
|
881
|
+
function _tpMissingNote(miss) {
|
|
882
|
+
if (!miss) return null;
|
|
883
|
+
const parts = [], tips = [];
|
|
884
|
+
if (miss.ingredients.length) {
|
|
885
|
+
const n = miss.ingredients.length;
|
|
886
|
+
parts.push(`${n} ingredient${n === 1 ? '' : 's'} missing ${miss.fields.map(x => x.field).join(', ')}`);
|
|
887
|
+
miss.fields.forEach(x => tips.push(`No ${x.field}: ${x.names.join(', ')}`));
|
|
888
|
+
tips.push('Those rows are not counted. Enter 0 where an ingredient truly has none.');
|
|
889
|
+
}
|
|
890
|
+
(miss.mixed || []).forEach(m => {
|
|
891
|
+
parts.push(`${m.field} in mixed units (${m.units.map(u => `${u.unit} ×${u.names.length}`).join(', ')})`);
|
|
892
|
+
tips.push(`${m.field} — ` + m.units.map(u => `${u.unit}: ${u.names.join(', ')}`).join('; ') + '. The sum reads the bare numbers, so convert them to one unit.');
|
|
893
|
+
});
|
|
894
|
+
if (miss.subs.length) {
|
|
895
|
+
const n = miss.subs.length;
|
|
896
|
+
parts.push(`${n} sub-formula row${n === 1 ? '' : 's'} not counted`);
|
|
897
|
+
tips.push(`Sub-formulas carry no ingredient values here: ${miss.subs.join(', ')}. Flatten the recipe, or account for them in the formula.`);
|
|
898
|
+
}
|
|
899
|
+
return parts.length ? { text: parts.join(' · '), tip: tips.join('\n') } : null;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function renderTheoreticalParamsHtml(entityType, entity) {
|
|
903
|
+
const params = _tpEffectiveParams(entityType, entity);
|
|
904
|
+
const { source, rows } = _tpEffectiveComposition(entityType, entity);
|
|
905
|
+
const evalRows = _tpBuildRows(rows);
|
|
906
|
+
const isBatch = entityType === 'batch';
|
|
907
|
+
const isOverride = isBatch && Array.isArray(entity.theoreticalParamsOverride);
|
|
908
|
+
|
|
909
|
+
const headerNote = isBatch
|
|
910
|
+
? (isOverride
|
|
911
|
+
? `<span class="badge badge-blue" style="margin-left:8px;font-size:11px" title="This batch has its own parameter list; won't change when the formula's params are edited">Override</span>`
|
|
912
|
+
: `<span style="margin-left:8px;font-size:11px;color:var(--text-muted)" title="Inheriting the formula's parameter list">inheriting from formula</span>`)
|
|
913
|
+
: '';
|
|
914
|
+
const compSourceChip = isBatch
|
|
915
|
+
? `<span style="font-size:11px;color:var(--text-muted)" title="${source === 'actual' ? 'Evaluated against the batch as-prepared composition' : 'Evaluated against the formula proposal; no batch actual logged yet'}">computed on <strong>${source === 'actual' ? 'actual' : 'proposal'}</strong></span>`
|
|
916
|
+
: '';
|
|
917
|
+
|
|
918
|
+
const actions = [];
|
|
919
|
+
actions.push(`<button class="btn btn-primary btn-sm" onclick="openTheoreticalParamModal('${entityType}','${entity.id}')">+ Add parameter</button>`);
|
|
920
|
+
if (typeof _tpPresetCatalog === 'function' && _tpPresetCatalog().length) {
|
|
921
|
+
actions.push(`<button class="btn btn-secondary btn-sm" onclick="openTheoreticalParamPresets('${entityType}','${entity.id}')" title="Pick from a catalog of common industry parameters (NV, OHV, PVC, VOC, energy, protein, …)">▤ Preset…</button>`);
|
|
922
|
+
}
|
|
923
|
+
if (isBatch && isOverride) {
|
|
924
|
+
actions.push(`<button class="btn btn-secondary btn-sm" onclick="resetBatchTheoreticalParams('${entity.id}')" title="Discard the override and inherit the formula's parameter list again"><span class="fl-ar fl-undo"></span> Reset to formula</button>`);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
const emptyState = !params.length
|
|
928
|
+
? `<div style="text-align:center;color:var(--text-muted);padding:24px;font-size:13px;line-height:1.55">
|
|
929
|
+
No theoretical parameters yet. Create a custom parameter or pick one from the
|
|
930
|
+
preset catalog to start tracking values.
|
|
931
|
+
</div>`
|
|
932
|
+
: '';
|
|
933
|
+
|
|
934
|
+
// Evaluate the whole list together so `[Param Name]` cross-references
|
|
935
|
+
// resolve in dependency order (with cycle detection).
|
|
936
|
+
const evalAll = _tpEvaluateAll(params, evalRows);
|
|
937
|
+
// Name → resolved value, for the "Show calculation" panel's [Param] references.
|
|
938
|
+
const _tpValueMap = {};
|
|
939
|
+
params.forEach(p => { const r = evalAll[p.id]; if (p.name && r && !r.error && Number.isFinite(r.value)) _tpValueMap[String(p.name).trim().toLowerCase()] = r.value; });
|
|
940
|
+
|
|
941
|
+
const rowsHtml = params.map(p => {
|
|
942
|
+
const r = evalAll[p.id] || { value: null, error: '' };
|
|
943
|
+
const value = r.error ? null : r.value;
|
|
944
|
+
const errorText = r.error || '';
|
|
945
|
+
const valueStr = errorText
|
|
946
|
+
? `<span style="color:var(--danger)" title="${esc(errorText)}">ERR</span>`
|
|
947
|
+
: (Number.isFinite(value) ? _tpFmtValue(value) : '—');
|
|
948
|
+
|
|
949
|
+
// Pass/fail vs. spec, if defined
|
|
950
|
+
const pfBadge = (!errorText && Number.isFinite(value)) ? _tpSpecBadgeHtml(value, p.spec) : '';
|
|
951
|
+
|
|
952
|
+
const miss = (!errorText && Number.isFinite(value)) ? _tpMissingNote(_tpMissingInputs(p.formula, evalRows)) : null;
|
|
953
|
+
const missHtml = miss ? `<div class="tp-miss" title="${esc(miss.tip)}" style="margin-top:3px;font-size:11px;font-weight:400;color:#b45309;cursor:help">⚠ ${esc(miss.text)}</div>` : '';
|
|
954
|
+
|
|
955
|
+
const formulaPreview = (p.formula || '').length > 80
|
|
956
|
+
? esc(p.formula.slice(0, 80)) + '…'
|
|
957
|
+
: esc(p.formula || '');
|
|
958
|
+
|
|
959
|
+
return `<tr style="border-bottom:1px solid #f1f5f9">
|
|
960
|
+
<td style="padding:8px 12px;font-weight:500;color:#0f172a">${esc(p.name || '—')}${p.pinned ? ` <span class="fl-pin" title="Pinned to the summary" role="img" aria-label="pinned" style="width:.8em;height:.8em;color:var(--primary);margin-left:3px"></span>` : ''}${missHtml}</td>
|
|
961
|
+
<td style="padding:8px 12px;text-align:right;font-variant-numeric:tabular-nums">
|
|
962
|
+
<strong>${valueStr}</strong>${p.unit ? ` <span translate="no" style="font-size:11px;color:#94a3b8">${esc(p.unit)}</span>` : ''}
|
|
963
|
+
</td>
|
|
964
|
+
<td style="padding:8px 12px;text-align:center">${pfBadge}</td>
|
|
965
|
+
<td style="padding:8px 12px;font-family:ui-monospace,monospace;font-size:11px;color:#475569" title="${esc(p.formula || '')}">
|
|
966
|
+
<span>${formulaPreview}</span>
|
|
967
|
+
${(!errorText && Number.isFinite(value)) ? `<button onclick="_tpToggleCalc('${p.id}')" title="Show how this value was calculated" style="margin-left:8px;font-family:inherit;font-size:10px;padding:1px 7px;background:#eff6ff;border:1px solid #bfdbfe;color:#1e40af;border-radius:9999px;cursor:pointer;white-space:nowrap">Show calc</button>` : ''}
|
|
968
|
+
</td>
|
|
969
|
+
<td style="padding:8px 12px;text-align:right;white-space:nowrap">
|
|
970
|
+
<button class="btn btn-secondary btn-sm btn-icon" onclick="openTheoreticalParamModal('${entityType}','${entity.id}','${p.id}')" title="Edit">${typeof ICON !== 'undefined' && ICON.edit ? ICON.edit : '✎'}</button>
|
|
971
|
+
<button class="btn btn-danger btn-sm btn-icon" onclick="confirmDeleteTheoreticalParam('${entityType}','${entity.id}','${p.id}','${escJsAttr(p.name||'')}')"><span class="fl-cross" aria-hidden="true"></span></button>
|
|
972
|
+
</td>
|
|
973
|
+
</tr>
|
|
974
|
+
${(!errorText && Number.isFinite(value)) ? `<tr id="tp-calc-${p.id}" style="display:none"><td colspan="5" style="padding:0">${_tpCalcHtml(p, evalRows, _tpValueMap)}</td></tr>` : ''}`;
|
|
975
|
+
}).join('');
|
|
976
|
+
|
|
977
|
+
const tableHtml = params.length ? `
|
|
978
|
+
<div style="overflow-x:auto">
|
|
979
|
+
<table style="width:100%;border-collapse:collapse;font-size:13px">
|
|
980
|
+
<thead>
|
|
981
|
+
<tr style="background:#f8fafc;border-bottom:2px solid var(--border)">
|
|
982
|
+
<th style="text-align:left;padding:8px 12px;font-size:11px;color:#64748b;text-transform:uppercase;letter-spacing:.05em">Parameter</th>
|
|
983
|
+
<th style="text-align:right;padding:8px 12px;font-size:11px;color:#64748b;text-transform:uppercase;letter-spacing:.05em">Value</th>
|
|
984
|
+
<th style="text-align:center;padding:8px 12px;font-size:11px;color:#64748b;text-transform:uppercase;letter-spacing:.05em" title="Pass/fail vs. spec, when a spec is defined">Result</th>
|
|
985
|
+
<th style="text-align:left;padding:8px 12px;font-size:11px;color:#64748b;text-transform:uppercase;letter-spacing:.05em">Formula</th>
|
|
986
|
+
<th></th>
|
|
987
|
+
</tr>
|
|
988
|
+
</thead>
|
|
989
|
+
<tbody>${rowsHtml}</tbody>
|
|
990
|
+
</table>
|
|
991
|
+
</div>` : '';
|
|
992
|
+
|
|
993
|
+
return `<div class="card" style="margin-bottom:16px">
|
|
994
|
+
<div class="card-header">
|
|
995
|
+
<h2>ƒx Theoretical Parameters ${headerNote}<button class="ai-help-btn" onclick="_aiAskInline('What are theoretical parameters in FormLab and how do I add or edit one?')" title="Ask AI to explain theoretical parameters">✦ Explain</button></h2>
|
|
996
|
+
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;justify-content:flex-end">
|
|
997
|
+
${compSourceChip}
|
|
998
|
+
${actions.join('')}
|
|
999
|
+
</div>
|
|
1000
|
+
</div>
|
|
1001
|
+
<div class="card-body" style="padding:${params.length ? '0' : '14px'}">
|
|
1002
|
+
${emptyState}
|
|
1003
|
+
${tableHtml}
|
|
1004
|
+
</div>
|
|
1005
|
+
</div>`;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// Compact read-only chips for parameters the user pinned (param.pinned),
|
|
1009
|
+
// for the formulation / batch summary bars. Each chip shows name: value unit
|
|
1010
|
+
// with a green/red tint when a spec is defined and the value is out of range.
|
|
1011
|
+
// Returns '' when nothing is pinned, so callers can append unconditionally.
|
|
1012
|
+
function _tpPinnedChipsHtml(entityType, entity) {
|
|
1013
|
+
if (!entity) return '';
|
|
1014
|
+
const allParams = _tpEffectiveParams(entityType, entity);
|
|
1015
|
+
const params = allParams.filter(p => p && p.pinned);
|
|
1016
|
+
if (!params.length) return '';
|
|
1017
|
+
const { rows } = _tpEffectiveComposition(entityType, entity);
|
|
1018
|
+
// Evaluate ALL params — not just the pinned subset — so a pinned parameter
|
|
1019
|
+
// that cross-references a NON-pinned sibling (e.g. "P:B ratio = [Pigment
|
|
1020
|
+
// loading] / [Binder]") still resolves. Evaluating only the pinned set left
|
|
1021
|
+
// those refs undefined → a spurious ERR chip even though the Params table
|
|
1022
|
+
// (which evaluates everything) showed a valid value.
|
|
1023
|
+
const chipRows = _tpBuildRows(rows);
|
|
1024
|
+
const evalAll = _tpEvaluateAll(allParams, chipRows);
|
|
1025
|
+
const chips = params.map(p => {
|
|
1026
|
+
const r = evalAll[p.id] || { value: null, error: '' };
|
|
1027
|
+
const valueStr = r.error
|
|
1028
|
+
? 'ERR'
|
|
1029
|
+
: (Number.isFinite(r.value) ? _tpFmtValue(r.value) : '—');
|
|
1030
|
+
// Spec tint (matches the table's pass/fail badge colours)
|
|
1031
|
+
let bg = '#f1f5f9', fg = '#334155', bd = '#e2e8f0';
|
|
1032
|
+
if (!r.error && Number.isFinite(r.value) && p.spec) {
|
|
1033
|
+
const okMin = p.spec.min == null || r.value >= p.spec.min;
|
|
1034
|
+
const okMax = p.spec.max == null || r.value <= p.spec.max;
|
|
1035
|
+
if (okMin && okMax) { bg = '#dcfce7'; fg = '#15803d'; bd = '#bbf7d0'; }
|
|
1036
|
+
else { bg = '#fee2e2'; fg = '#b91c1c'; bd = '#fecaca'; }
|
|
1037
|
+
} else if (r.error) { fg = '#b91c1c'; }
|
|
1038
|
+
const miss = (!r.error && Number.isFinite(r.value)) ? _tpMissingNote(_tpMissingInputs(p.formula, chipRows)) : null;
|
|
1039
|
+
const title = r.error ? r.error : ((p.description || `${p.name}${p.unit ? ' (' + p.unit + ')' : ''}`) + (miss ? '\n⚠ ' + miss.text + '\n' + miss.tip : ''));
|
|
1040
|
+
return `<span title="${esc(title)}" style="display:inline-flex;align-items:baseline;gap:5px;padding:3px 9px;border-radius:9999px;background:${bg};border:1px solid ${bd};font-size:11.5px;line-height:1.4;white-space:nowrap">
|
|
1041
|
+
<span style="color:#64748b">${esc(p.name)}</span>
|
|
1042
|
+
<strong style="color:${fg};font-variant-numeric:tabular-nums">${esc(valueStr)}</strong>${miss ? '<span style="color:#b45309" aria-label="some ingredient values missing">⚠</span>' : ''}${p.unit ? `<span translate="no" style="color:#94a3b8;font-size:10.5px">${esc(p.unit)}</span>` : ''}
|
|
1043
|
+
</span>`;
|
|
1044
|
+
}).join('');
|
|
1045
|
+
return `<div class="tp-pinned-chips" style="display:flex;flex-wrap:wrap;gap:6px;align-items:center" title="Pinned theoretical parameters">
|
|
1046
|
+
<span style="font-size:11px;color:#94a3b8" aria-hidden="true">ƒx</span>${chips}
|
|
1047
|
+
</div>`;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// Number formatting that mirrors the rest of the testing/stability UI
|
|
1051
|
+
function _tpFmtValue(v) {
|
|
1052
|
+
if (!Number.isFinite(v)) return '—';
|
|
1053
|
+
const a = Math.abs(v);
|
|
1054
|
+
if (a === 0) return '0';
|
|
1055
|
+
// Large values: readable thousands-separated, or compact k/M/B for very big —
|
|
1056
|
+
// never scientific notation (a cost figure read as "6.28e+4", which looks broken).
|
|
1057
|
+
if (a >= 1e9) return (v / 1e9).toFixed(2).replace(/\.?0+$/, '') + 'B';
|
|
1058
|
+
if (a >= 1e6) return (v / 1e6).toFixed(2).replace(/\.?0+$/, '') + 'M';
|
|
1059
|
+
if (a >= 10000) return Math.round(v).toLocaleString('en-US');
|
|
1060
|
+
if (a >= 100) return v.toFixed(1);
|
|
1061
|
+
if (a >= 1) return v.toFixed(2);
|
|
1062
|
+
if (a >= 0.01) return v.toFixed(3);
|
|
1063
|
+
return v.toPrecision(2);
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
// Pass/fail pill for a computed value against a { min, max } spec (either
|
|
1067
|
+
// bound optional). '' when there's no spec to judge against. Shared by the
|
|
1068
|
+
// saved params table and the modal's live preview — the preview passes the
|
|
1069
|
+
// spec fields' CURRENT (possibly unsaved) values instead of p.spec, so
|
|
1070
|
+
// editing Spec min/max updates the color immediately, same as editing the
|
|
1071
|
+
// formula already did (Jun, 2026-09-22).
|
|
1072
|
+
function _tpSpecBadgeHtml(value, spec) {
|
|
1073
|
+
if (!Number.isFinite(value) || !spec || (spec.min == null && spec.max == null)) return '';
|
|
1074
|
+
const okMin = spec.min == null || value >= spec.min;
|
|
1075
|
+
const okMax = spec.max == null || value <= spec.max;
|
|
1076
|
+
const pass = okMin && okMax;
|
|
1077
|
+
const min = spec.min != null ? _tpFmtValue(spec.min) : '–∞';
|
|
1078
|
+
const max = spec.max != null ? _tpFmtValue(spec.max) : '+∞';
|
|
1079
|
+
return pass
|
|
1080
|
+
? `<span style="display:inline-block;padding:1px 7px;border-radius:9999px;background:#dcfce7;color:#15803d;font-size:11px;font-weight:500" title="Within spec ${min}..${max}"><span class="fl-check" aria-hidden="true"></span></span>`
|
|
1081
|
+
: `<span style="display:inline-block;padding:1px 7px;border-radius:9999px;background:#fee2e2;color:#b91c1c;font-size:11px;font-weight:500" title="Outside spec ${min}..${max}"><span class="fl-cross" aria-hidden="true"></span></span>`;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// ---- Modal ----
|
|
1085
|
+
//
|
|
1086
|
+
// Add/edit a single parameter. Live preview at the bottom shows the
|
|
1087
|
+
// computed value against the current composition AND lists the rows
|
|
1088
|
+
// used + any ingredients that are missing a referenced field. Helps
|
|
1089
|
+
// the user debug their formula before saving.
|
|
1090
|
+
// ---- Formula builder palette (click-to-insert) -------------------------
|
|
1091
|
+
// Non-programmer aid for the theoretical-parameter editor: instead of
|
|
1092
|
+
// memorizing the row.* / ing.* vocabulary, users click a chip and the token
|
|
1093
|
+
// drops into the formula at the cursor. The ingredient chips are built from
|
|
1094
|
+
// the ACTUAL composition, so only fields that exist on these ingredients
|
|
1095
|
+
// (standard + custom, and only when populated) are offered.
|
|
1096
|
+
function _tpInsert(token) {
|
|
1097
|
+
const ta = document.getElementById('tp-formula');
|
|
1098
|
+
if (!ta || !token) return;
|
|
1099
|
+
const start = ta.selectionStart ?? ta.value.length;
|
|
1100
|
+
const end = ta.selectionEnd ?? ta.value.length;
|
|
1101
|
+
const before = ta.value.slice(0, start);
|
|
1102
|
+
const after = ta.value.slice(end);
|
|
1103
|
+
// Auto-space so a clicked token doesn't fuse onto the previous one.
|
|
1104
|
+
const needsSpace = before.length > 0 && !/[\s(*/+\-,]$/.test(before);
|
|
1105
|
+
const ins = (needsSpace ? ' ' : '') + token;
|
|
1106
|
+
ta.value = before + ins + after;
|
|
1107
|
+
// Caret lands INSIDE a trailing "()" (an aggregation) so the next click
|
|
1108
|
+
// fills its argument; otherwise just after the inserted token.
|
|
1109
|
+
const caret = token.endsWith('()') ? start + ins.length - 1 : start + ins.length;
|
|
1110
|
+
ta.focus();
|
|
1111
|
+
ta.setSelectionRange(caret, caret);
|
|
1112
|
+
ta.dispatchEvent(new Event('input', { bubbles: true })); // fires oninput → live preview
|
|
1113
|
+
}
|
|
1114
|
+
function _tpInsertFromEl(el) { if (el) _tpInsert(el.getAttribute('data-tok') || ''); }
|
|
1115
|
+
|
|
1116
|
+
// Build the click-to-insert palette for THIS entity's fields.
|
|
1117
|
+
function _tpFieldPaletteHtml(evalRows, params, currentParamId) {
|
|
1118
|
+
const chip = (tok, label, title) =>
|
|
1119
|
+
`<button type="button" class="tp-chip" data-tok="${esc(tok)}" onclick="_tpInsertFromEl(this)" title="${esc(title || tok)}">${esc(label || tok)}</button>`;
|
|
1120
|
+
const group = (label, chips) => chips
|
|
1121
|
+
? `<div class="tp-palette-group"><span class="tp-palette-label">${esc(label)}</span>${chips}</div>` : '';
|
|
1122
|
+
|
|
1123
|
+
// Aggregations — the wrapper a row/ing field must sit inside.
|
|
1124
|
+
const aggs = ['SUM', 'AVG', 'MIN', 'MAX', 'COUNT']
|
|
1125
|
+
.map(a => chip(`${a}()`, `${a}()`, `${a} across all ingredient rows`)).join('')
|
|
1126
|
+
+ chip('WHERE', 'WHERE', 'Keep only some rows, e.g. SUM(row.amount) WHERE ing.purity > 0');
|
|
1127
|
+
|
|
1128
|
+
// Row fields — always available.
|
|
1129
|
+
const rowChips = [
|
|
1130
|
+
['row.amount', 'the amount / weight of the row'],
|
|
1131
|
+
['row.role', 'the row’s role (text — use in WHERE)'],
|
|
1132
|
+
['row.unit', 'the row’s unit (text — use in WHERE)'],
|
|
1133
|
+
].map(([t, d]) => chip(t, t, d)).join('');
|
|
1134
|
+
|
|
1135
|
+
// Ingredient fields — ONLY those populated on ≥1 ingredient in this recipe.
|
|
1136
|
+
const seen = new Map(); // fieldName -> { count, sample, numeric }
|
|
1137
|
+
(evalRows || []).forEach(r => {
|
|
1138
|
+
const ing = r.ing; if (!ing || ing._isSubFormula) return;
|
|
1139
|
+
Object.keys(ing).forEach(k => {
|
|
1140
|
+
const val = ing[k];
|
|
1141
|
+
if (val == null || val === '' || k === 'uid') return;
|
|
1142
|
+
const e = seen.get(k) || { count: 0, sample: val, numeric: typeof val === 'number' };
|
|
1143
|
+
e.count++; seen.set(k, e);
|
|
1144
|
+
});
|
|
1145
|
+
});
|
|
1146
|
+
// Token form: bare identifiers use ing.x; names with spaces/symbols use ing['x'].
|
|
1147
|
+
const tokenFor = (name) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) ? `ing.${name}` : `ing['${String(name).replace(/'/g, "\\'")}']`;
|
|
1148
|
+
const entries = [...seen.entries()];
|
|
1149
|
+
const fieldChip = ([name, e]) => chip(tokenFor(name), tokenFor(name),
|
|
1150
|
+
`${e.numeric ? 'number' : 'text'} · e.g. ${String(e.sample).slice(0, 18)} · on ${e.count} ingredient${e.count === 1 ? '' : 's'}`);
|
|
1151
|
+
const numChips = entries.filter(([, e]) => e.numeric).sort((a, b) => b[1].count - a[1].count).map(fieldChip).join('');
|
|
1152
|
+
const txtChips = entries.filter(([, e]) => !e.numeric).sort((a, b) => b[1].count - a[1].count).slice(0, 10).map(fieldChip).join('');
|
|
1153
|
+
|
|
1154
|
+
// Other parameters — referenceable by [Name].
|
|
1155
|
+
const paramChips = (params || [])
|
|
1156
|
+
.filter(pp => pp.id !== currentParamId && pp.name)
|
|
1157
|
+
.map(pp => chip(`[${pp.name}]`, `[${pp.name}]`, 'Use this parameter’s computed value')).join('');
|
|
1158
|
+
|
|
1159
|
+
const numNote = `<span style="font-size:11px;color:#b45309">No numeric fields on these ingredients yet — add values like density, or a custom field such as “solids”, on the Ingredients page to use them here.</span>`;
|
|
1160
|
+
|
|
1161
|
+
return `
|
|
1162
|
+
<div style="margin-top:6px;padding:9px 11px;background:#f8fafc;border:1px solid var(--border);border-radius:6px">
|
|
1163
|
+
<div style="font-size:11.5px;color:var(--text-muted);margin-bottom:7px;line-height:1.55">
|
|
1164
|
+
<strong style="color:var(--text)">Click to build.</strong> A formula runs across every ingredient row, so wrap fields in an aggregation like <code>SUM()</code> or <code>AVG()</code>. Try: click <code>AVG()</code>, then (cursor inside the brackets) click <code>ing.density</code>.
|
|
1165
|
+
</div>
|
|
1166
|
+
${group('Aggregations', aggs)}
|
|
1167
|
+
${group('Row fields', rowChips)}
|
|
1168
|
+
${group('Ingredient numbers', numChips || numNote)}
|
|
1169
|
+
${group('Ingredient text', txtChips)}
|
|
1170
|
+
${group('Other parameters', paramChips)}
|
|
1171
|
+
</div>`;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
function openTheoreticalParamModal(entityType, entityId, paramId) {
|
|
1175
|
+
const entity = entityType === 'batch'
|
|
1176
|
+
? getBatch(entityId)
|
|
1177
|
+
: db.formulations.find(x => x.id === entityId);
|
|
1178
|
+
if (!entity) return;
|
|
1179
|
+
const params = _tpEffectiveParams(entityType, entity);
|
|
1180
|
+
const p = paramId ? params.find(x => x.id === paramId) : null;
|
|
1181
|
+
const { rows: compRows } = _tpEffectiveComposition(entityType, entity);
|
|
1182
|
+
const evalRows = _tpBuildRows(compRows);
|
|
1183
|
+
|
|
1184
|
+
const initialName = p?.name || '';
|
|
1185
|
+
const initialFormula = p?.formula || '';
|
|
1186
|
+
const initialUnit = p?.unit || '';
|
|
1187
|
+
const initialSpecMin = p?.spec?.min != null ? String(p.spec.min) : '';
|
|
1188
|
+
const initialSpecMax = p?.spec?.max != null ? String(p.spec.max) : '';
|
|
1189
|
+
const initialDesc = p?.description || '';
|
|
1190
|
+
const initialPinned = !!p?.pinned;
|
|
1191
|
+
|
|
1192
|
+
showModal(`${p ? 'Edit' : 'Add'} Theoretical Parameter`, `
|
|
1193
|
+
<div class="form-grid">
|
|
1194
|
+
<div class="form-group span-2">
|
|
1195
|
+
<label>Name *</label>
|
|
1196
|
+
<input id="tp-name" value="${esc(initialName)}" placeholder="e.g. Non-Volatiles" />
|
|
1197
|
+
</div>
|
|
1198
|
+
<div class="form-group span-2">
|
|
1199
|
+
<label>Formula *</label>
|
|
1200
|
+
<textarea id="tp-formula" style="min-height:60px;font-family:ui-monospace,monospace;font-size:13px"
|
|
1201
|
+
placeholder="AVG(ing.density) — or click the chips below to build one"
|
|
1202
|
+
oninput="_tpRefreshPreview('${entityType}','${entityId}')">${esc(initialFormula)}</textarea>
|
|
1203
|
+
${_tpFieldPaletteHtml(evalRows, params, p?.id)}
|
|
1204
|
+
</div>
|
|
1205
|
+
<div class="form-group">
|
|
1206
|
+
<label>Live preview</label>
|
|
1207
|
+
<div id="tp-preview" class="tp-preview-card"></div>
|
|
1208
|
+
</div>
|
|
1209
|
+
<div class="form-group">
|
|
1210
|
+
<label>Unit</label>
|
|
1211
|
+
<input id="tp-unit" value="${esc(initialUnit)}" placeholder="%, mg KOH/g, g/L, kcal/100g, …" />
|
|
1212
|
+
</div>
|
|
1213
|
+
<div class="form-group">
|
|
1214
|
+
<label>Spec min <span style="font-weight:400;color:var(--text-muted);text-transform:none;letter-spacing:0">(optional)</span></label>
|
|
1215
|
+
<input id="tp-spec-min" type="number" step="any" value="${esc(initialSpecMin)}" oninput="_tpRefreshPreview('${entityType}','${entityId}')" />
|
|
1216
|
+
</div>
|
|
1217
|
+
<div class="form-group">
|
|
1218
|
+
<label>Spec max <span style="font-weight:400;color:var(--text-muted);text-transform:none;letter-spacing:0">(optional)</span></label>
|
|
1219
|
+
<input id="tp-spec-max" type="number" step="any" value="${esc(initialSpecMax)}" oninput="_tpRefreshPreview('${entityType}','${entityId}')" />
|
|
1220
|
+
</div>
|
|
1221
|
+
<div class="form-group span-2">
|
|
1222
|
+
<label>Description <span style="font-weight:400;color:var(--text-muted);text-transform:none;letter-spacing:0">(optional; shown on hover)</span></label>
|
|
1223
|
+
<input id="tp-desc" value="${esc(initialDesc)}" placeholder="What this parameter represents and why it matters" />
|
|
1224
|
+
</div>
|
|
1225
|
+
<div class="form-group span-2">
|
|
1226
|
+
<label style="display:flex;align-items:flex-start;gap:8px;cursor:pointer;text-transform:none;letter-spacing:0;font-weight:500">
|
|
1227
|
+
<input id="tp-pinned" type="checkbox" ${initialPinned ? 'checked' : ''} style="width:auto;margin:0" />
|
|
1228
|
+
Pin to summary<br><span style="font-weight:400;color:var(--text-muted)">Shows this value as a chip on the formula & batch summary, not just the parameters tab</span>
|
|
1229
|
+
</label>
|
|
1230
|
+
</div>
|
|
1231
|
+
</div>
|
|
1232
|
+
`, () => {
|
|
1233
|
+
const name = (v('tp-name') || '').trim();
|
|
1234
|
+
if (!name) { toast('Name is required.'); return false; }
|
|
1235
|
+
const formula = (v('tp-formula') || '').trim();
|
|
1236
|
+
if (!formula) { toast('Formula is required.'); return false; }
|
|
1237
|
+
// Compile-check before save — surface parse errors at the modal. Resolve
|
|
1238
|
+
// `[Param Name]` cross-references to a placeholder number FIRST (the live
|
|
1239
|
+
// preview does the same with real values), otherwise a valid cross-ref like
|
|
1240
|
+
// `[Total weight] / COUNT()` is parsed as raw JS — `[Total weight]` reads as
|
|
1241
|
+
// an array literal → "Unexpected identifier 'weight'". This is a syntax
|
|
1242
|
+
// check only; real ref resolution (unknown name / cycle → ERR) happens at
|
|
1243
|
+
// eval time in _tpEvaluateAll, so we don't block saving a forward-reference.
|
|
1244
|
+
const checkSrc = (typeof _tpSubstituteParamRefs === 'function')
|
|
1245
|
+
? _tpSubstituteParamRefs(formula, {}) // unknown refs → (NaN); fine for a parse check
|
|
1246
|
+
: formula;
|
|
1247
|
+
try { _tpCompile(checkSrc); } catch (e) { toast(`Formula error: ${e.message}`); return false; }
|
|
1248
|
+
const data = {
|
|
1249
|
+
name,
|
|
1250
|
+
formula,
|
|
1251
|
+
unit: (v('tp-unit') || '').trim(),
|
|
1252
|
+
description: (v('tp-desc') || '').trim(),
|
|
1253
|
+
pinned: !!document.getElementById('tp-pinned')?.checked,
|
|
1254
|
+
spec: null,
|
|
1255
|
+
};
|
|
1256
|
+
const sMin = v('tp-spec-min'); const sMax = v('tp-spec-max');
|
|
1257
|
+
const minNum = sMin === '' ? null : parseFloat(sMin);
|
|
1258
|
+
const maxNum = sMax === '' ? null : parseFloat(sMax);
|
|
1259
|
+
if (minNum != null || maxNum != null) {
|
|
1260
|
+
data.spec = { min: Number.isFinite(minNum) ? minNum : null, max: Number.isFinite(maxNum) ? maxNum : null };
|
|
1261
|
+
}
|
|
1262
|
+
if (p) updateTheoreticalParam(entityType, entityId, p.id, data);
|
|
1263
|
+
else addTheoreticalParam(entityType, entityId, data);
|
|
1264
|
+
if (entityType === 'batch') renderBatchDetail(entityId);
|
|
1265
|
+
else renderFormulationDetail(entityId);
|
|
1266
|
+
});
|
|
1267
|
+
|
|
1268
|
+
// A touch wider than the default modal — the formula, click-to-build palette
|
|
1269
|
+
// and the result card breathe better with the extra room (chips wrap less).
|
|
1270
|
+
document.querySelector('#modal-backdrop > .modal')?.classList.add('tp-modal-wide');
|
|
1271
|
+
// Fire an initial preview render
|
|
1272
|
+
setTimeout(() => _tpRefreshPreview(entityType, entityId), 0);
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
// Render the live preview underneath the formula textarea.
|
|
1276
|
+
function _tpRefreshPreview(entityType, entityId) {
|
|
1277
|
+
const wrap = document.getElementById('tp-preview');
|
|
1278
|
+
if (!wrap) return;
|
|
1279
|
+
const formula = (document.getElementById('tp-formula')?.value || '').trim();
|
|
1280
|
+
wrap.classList.remove('tp-preview-error');
|
|
1281
|
+
if (!formula) {
|
|
1282
|
+
wrap.innerHTML = '<span style="color:var(--text-muted)">Build or type a formula above — its computed value appears here as you go.</span>';
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
const entity = entityType === 'batch' ? getBatch(entityId) : db.formulations.find(x => x.id === entityId);
|
|
1286
|
+
if (!entity) { wrap.innerHTML = '<span style="color:var(--danger)">Entity not found.</span>'; return; }
|
|
1287
|
+
const { source, rows } = _tpEffectiveComposition(entityType, entity);
|
|
1288
|
+
const evalRows = _tpBuildRows(rows);
|
|
1289
|
+
const ingMissingFields = new Set();
|
|
1290
|
+
// Detect refs to ing.X / ing['X'] so we can warn when the row's ingredient lacks them
|
|
1291
|
+
const referencedIngFields = new Set();
|
|
1292
|
+
const re = /\bing\s*(?:\.\s*([A-Za-z_][A-Za-z0-9_]*)|\[\s*['"]([^'"]+)['"]\s*\])/g;
|
|
1293
|
+
let m;
|
|
1294
|
+
while ((m = re.exec(formula)) !== null) referencedIngFields.add(m[1] || m[2]);
|
|
1295
|
+
evalRows.forEach(r => {
|
|
1296
|
+
referencedIngFields.forEach(field => {
|
|
1297
|
+
if (r.ing && r.ing[field] == null && !r.ing._isSubFormula) {
|
|
1298
|
+
ingMissingFields.add(`${r.ing.name || '?'}: ${field}`);
|
|
1299
|
+
}
|
|
1300
|
+
});
|
|
1301
|
+
});
|
|
1302
|
+
// Resolve `[Param Name]` cross-references against the entity's other
|
|
1303
|
+
// parameters so the live preview matches what the saved table will show.
|
|
1304
|
+
const otherParams = _tpEffectiveParams(entityType, entity);
|
|
1305
|
+
const otherResults = _tpEvaluateAll(otherParams, evalRows);
|
|
1306
|
+
const paramValueMap = {};
|
|
1307
|
+
const knownParamNames = new Set();
|
|
1308
|
+
otherParams.forEach(p => {
|
|
1309
|
+
const nm = String(p.name || '').trim().toLowerCase();
|
|
1310
|
+
if (!nm) return;
|
|
1311
|
+
knownParamNames.add(nm);
|
|
1312
|
+
const r = otherResults[p.id];
|
|
1313
|
+
if (r && Number.isFinite(r.value)) paramValueMap[nm] = r.value;
|
|
1314
|
+
});
|
|
1315
|
+
const unknownRefs = _tpScanParamRefs(formula)
|
|
1316
|
+
.map(r => r.name)
|
|
1317
|
+
.filter(n => !knownParamNames.has(n.toLowerCase()));
|
|
1318
|
+
|
|
1319
|
+
let result;
|
|
1320
|
+
try {
|
|
1321
|
+
result = _tpEvaluate(_tpSubstituteParamRefs(formula, paramValueMap), evalRows);
|
|
1322
|
+
} catch (e) {
|
|
1323
|
+
wrap.classList.add('tp-preview-error');
|
|
1324
|
+
wrap.innerHTML = `<div style="color:var(--danger);font-size:12.5px"><strong>Can’t compute yet:</strong> ${esc(e.message)}</div>`;
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1327
|
+
const valueStr = Number.isFinite(result) ? _tpFmtValue(result) : (result == null ? '—' : String(result));
|
|
1328
|
+
const unit = (document.getElementById('tp-unit')?.value || '').trim();
|
|
1329
|
+
const sourceChip = entityType === 'batch'
|
|
1330
|
+
? `<span style="color:var(--text-muted);font-size:11px;margin-left:6px">on ${source === 'actual' ? 'actual composition' : 'formula proposal'}</span>`
|
|
1331
|
+
: '';
|
|
1332
|
+
// Judged against the Spec min/max fields' CURRENT values, not a saved
|
|
1333
|
+
// p.spec — so editing them updates this color before the row is saved,
|
|
1334
|
+
// the same live behavior the formula field already had.
|
|
1335
|
+
const specMinRaw = document.getElementById('tp-spec-min')?.value;
|
|
1336
|
+
const specMaxRaw = document.getElementById('tp-spec-max')?.value;
|
|
1337
|
+
const liveSpec = {
|
|
1338
|
+
min: specMinRaw !== '' && specMinRaw != null ? parseFloat(specMinRaw) : null,
|
|
1339
|
+
max: specMaxRaw !== '' && specMaxRaw != null ? parseFloat(specMaxRaw) : null,
|
|
1340
|
+
};
|
|
1341
|
+
const pfBadge = _tpSpecBadgeHtml(result, liveSpec);
|
|
1342
|
+
const missingHtml = ingMissingFields.size
|
|
1343
|
+
? `<div style="margin-top:6px;color:#d97706;font-size:11.5px"><strong><span class="fl-warn" aria-hidden="true"></span> ${ingMissingFields.size} missing field${ingMissingFields.size===1?'':'s'}:</strong> ${[...ingMissingFields].slice(0, 4).map(esc).join(', ')}${ingMissingFields.size > 4 ? '…' : ''}</div>`
|
|
1344
|
+
: '';
|
|
1345
|
+
const unknownRefHtml = unknownRefs.length
|
|
1346
|
+
? `<div style="margin-top:6px;color:#d97706;font-size:11.5px"><strong><span class="fl-warn" aria-hidden="true"></span> Unknown parameter${unknownRefs.length===1?'':'s'}:</strong> ${unknownRefs.slice(0, 4).map(n => esc('[' + n + ']')).join(', ')}${unknownRefs.length > 4 ? '…' : ''} — check the name matches another parameter exactly.</div>`
|
|
1347
|
+
: '';
|
|
1348
|
+
wrap.innerHTML = `
|
|
1349
|
+
<div style="display:flex;align-items:baseline;gap:8px;flex-wrap:wrap">
|
|
1350
|
+
<span style="font-size:15px;color:#94a3b8;font-weight:600" aria-hidden="true">=</span>
|
|
1351
|
+
<span style="font-size:28px;line-height:1.1;font-weight:700;color:var(--primary);font-variant-numeric:tabular-nums">${esc(valueStr)}</span>
|
|
1352
|
+
${unit ? `<span translate="no" style="font-size:14px;color:var(--text-muted);font-weight:500">${esc(unit)}</span>` : ''}
|
|
1353
|
+
${pfBadge}
|
|
1354
|
+
${sourceChip}
|
|
1355
|
+
</div>
|
|
1356
|
+
<div style="margin-top:5px;color:var(--text-muted);font-size:11.5px">Evaluated over ${evalRows.length} composition row${evalRows.length===1?'':'s'}.</div>
|
|
1357
|
+
${missingHtml}
|
|
1358
|
+
${unknownRefHtml}
|
|
1359
|
+
`;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
// ---- Preset catalog ----
|
|
1363
|
+
//
|
|
1364
|
+
// Industry-standard parameters users can one-click add. Drawn from the
|
|
1365
|
+
// Allnex/Morgan/BASF gap analyses + common coatings practice. Each
|
|
1366
|
+
// preset is the same shape as a stored parameter; click to add to the
|
|
1367
|
+
// current formulation/batch.
|
|
1368
|
+
function _tpPresetCatalog() {
|
|
1369
|
+
return [
|
|
1370
|
+
// ---- Coatings ----
|
|
1371
|
+
{
|
|
1372
|
+
group: 'Coatings',
|
|
1373
|
+
name: 'Non-Volatiles (NV)',
|
|
1374
|
+
unit: '%',
|
|
1375
|
+
formula: "SUM(row.amount * ing.solids) / SUM(row.amount)",
|
|
1376
|
+
description: 'Weighted average of ingredient solids %, weighted by row amount. Needs ing.solids (custom field, 0–100).',
|
|
1377
|
+
},
|
|
1378
|
+
{
|
|
1379
|
+
group: 'Coatings',
|
|
1380
|
+
name: 'Hydroxylic Value (OHV)',
|
|
1381
|
+
unit: 'mg KOH/g',
|
|
1382
|
+
formula: "56100 * SUM(row.amount * ing['OH mol']) / SUM(row.amount)",
|
|
1383
|
+
description: 'OHV = 56100 × Σ(weight × mol OH) / total weight. Needs ing[\"OH mol\"] (mol OH per gram).',
|
|
1384
|
+
},
|
|
1385
|
+
{
|
|
1386
|
+
group: 'Coatings',
|
|
1387
|
+
name: 'Acid Value (AV)',
|
|
1388
|
+
unit: 'mg KOH/g',
|
|
1389
|
+
formula: "56100 * SUM(row.amount * ing['acid mol']) / SUM(row.amount)",
|
|
1390
|
+
description: 'AV = 56100 × Σ(weight × mol acid) / total weight. Needs ing[\"acid mol\"] (mol COOH per gram).',
|
|
1391
|
+
},
|
|
1392
|
+
{
|
|
1393
|
+
group: 'Coatings',
|
|
1394
|
+
name: 'Pigment Volume Concentration (PVC)',
|
|
1395
|
+
unit: '%',
|
|
1396
|
+
formula: "100 * SUM(row.amount / ing.density WHERE ing.class == 'Pigment') / SUM(row.amount / ing.density WHERE ing.density)",
|
|
1397
|
+
description: 'PVC = pigment volume / total non-volatile volume. Needs ing.density and ing.class on each row.',
|
|
1398
|
+
},
|
|
1399
|
+
{
|
|
1400
|
+
group: 'Coatings',
|
|
1401
|
+
name: 'VOC (g/L)',
|
|
1402
|
+
unit: 'g/L',
|
|
1403
|
+
formula: "1000 * SUM(row.amount WHERE ing.class == 'Solvent') / SUM(row.amount / ing.density WHERE ing.density)",
|
|
1404
|
+
description: 'Volatile-organic-compound load per litre of paint. Needs ing.class and ing.density.',
|
|
1405
|
+
},
|
|
1406
|
+
{
|
|
1407
|
+
group: 'Coatings',
|
|
1408
|
+
name: 'Solids by Weight',
|
|
1409
|
+
unit: '%',
|
|
1410
|
+
formula: "100 * SUM(row.amount * ing.solids) / (100 * SUM(row.amount))",
|
|
1411
|
+
description: 'Same as NV but rescaled to 0–100 if ing.solids is stored as fraction (0–1).',
|
|
1412
|
+
},
|
|
1413
|
+
// ---- Surfactants / emulsions ----
|
|
1414
|
+
{
|
|
1415
|
+
group: 'Surfactants',
|
|
1416
|
+
name: 'Blend HLB',
|
|
1417
|
+
unit: '',
|
|
1418
|
+
formula: "SUM(row.amount * ing.hlb WHERE ing.hlb) / SUM(row.amount WHERE ing.hlb)",
|
|
1419
|
+
description: 'Weight-average HLB of the emulsifier blend (only rows that carry an HLB count). Match it to the oil phase\'s required HLB. Needs ing.hlb (Surfactant details, 0–20).',
|
|
1420
|
+
},
|
|
1421
|
+
// ---- Food & nutrition ----
|
|
1422
|
+
// Per 100 g of product: Σ(row wt% × ingredient value per 100 g) / Σ(row wt%).
|
|
1423
|
+
// Dividing by SUM(row.amount), not 100, keeps it right when the rows don't
|
|
1424
|
+
// add up to exactly 100 (grams are converted to wt% before this runs).
|
|
1425
|
+
{
|
|
1426
|
+
group: 'Food & nutrition',
|
|
1427
|
+
name: 'Energy',
|
|
1428
|
+
unit: 'kcal/100g',
|
|
1429
|
+
formula: 'SUM(row.amount * (4 * ing.protein + 4 * ing.carb + 9 * ing.fat)) / SUM(row.amount)',
|
|
1430
|
+
description: 'Energy from the general Atwater factors: 4 kcal/g protein, 4 kcal/g carbohydrate, 9 kcal/g fat. Needs ing.protein, ing.carb and ing.fat (g/100g). An ingredient missing any one of the three adds nothing at all, so fill all three.',
|
|
1431
|
+
},
|
|
1432
|
+
{
|
|
1433
|
+
group: 'Food & nutrition',
|
|
1434
|
+
name: 'Energy (kJ)',
|
|
1435
|
+
unit: 'kJ/100g',
|
|
1436
|
+
formula: 'SUM(row.amount * (17 * ing.protein + 17 * ing.carb + 37 * ing.fat)) / SUM(row.amount)',
|
|
1437
|
+
description: 'Energy in kilojoules: 17 kJ/g protein, 17 kJ/g carbohydrate, 37 kJ/g fat. Needs ing.protein, ing.carb and ing.fat (g/100g).',
|
|
1438
|
+
},
|
|
1439
|
+
{
|
|
1440
|
+
group: 'Food & nutrition',
|
|
1441
|
+
name: 'Protein',
|
|
1442
|
+
unit: 'g/100g',
|
|
1443
|
+
formula: 'SUM(row.amount * ing.protein) / SUM(row.amount)',
|
|
1444
|
+
description: 'Protein per 100 g of product. Needs ing.protein (g/100g).',
|
|
1445
|
+
},
|
|
1446
|
+
{
|
|
1447
|
+
group: 'Food & nutrition',
|
|
1448
|
+
name: 'Total fat',
|
|
1449
|
+
unit: 'g/100g',
|
|
1450
|
+
formula: 'SUM(row.amount * ing.fat) / SUM(row.amount)',
|
|
1451
|
+
description: 'Fat per 100 g of product. Needs ing.fat (g/100g).',
|
|
1452
|
+
},
|
|
1453
|
+
{
|
|
1454
|
+
group: 'Food & nutrition',
|
|
1455
|
+
name: 'Carbohydrate',
|
|
1456
|
+
unit: 'g/100g',
|
|
1457
|
+
formula: 'SUM(row.amount * ing.carb) / SUM(row.amount)',
|
|
1458
|
+
description: 'Carbohydrate per 100 g of product. Needs ing.carb (g/100g).',
|
|
1459
|
+
},
|
|
1460
|
+
{
|
|
1461
|
+
group: 'Food & nutrition',
|
|
1462
|
+
name: 'Total sugars',
|
|
1463
|
+
unit: 'g/100g',
|
|
1464
|
+
formula: 'SUM(row.amount * ing.sugar) / SUM(row.amount)',
|
|
1465
|
+
description: 'Sugars per 100 g of product. Needs ing.sugar (g/100g).',
|
|
1466
|
+
},
|
|
1467
|
+
{
|
|
1468
|
+
group: 'Food & nutrition',
|
|
1469
|
+
name: 'Sodium',
|
|
1470
|
+
unit: 'mg/100g',
|
|
1471
|
+
formula: 'SUM(row.amount * ing.sodium) / SUM(row.amount)',
|
|
1472
|
+
description: 'Sodium per 100 g of product. Needs ing.sodium in mg/100g (table salt is about 39,300).',
|
|
1473
|
+
},
|
|
1474
|
+
{
|
|
1475
|
+
group: 'Food & nutrition',
|
|
1476
|
+
name: 'Salt',
|
|
1477
|
+
unit: 'g/100g',
|
|
1478
|
+
formula: '2.5 * SUM(row.amount * ing.sodium) / SUM(row.amount) / 1000',
|
|
1479
|
+
description: 'Salt equivalent as on EU labels: sodium × 2.5, in g/100g. Needs ing.sodium in mg/100g.',
|
|
1480
|
+
},
|
|
1481
|
+
// ---- General ----
|
|
1482
|
+
{
|
|
1483
|
+
group: 'General',
|
|
1484
|
+
name: 'Total Weight',
|
|
1485
|
+
unit: 'g',
|
|
1486
|
+
formula: 'SUM(row.amount)',
|
|
1487
|
+
description: 'Total mass of all composition rows (assumes amounts are in the same unit).',
|
|
1488
|
+
},
|
|
1489
|
+
{
|
|
1490
|
+
group: 'General',
|
|
1491
|
+
name: 'Cost / kg',
|
|
1492
|
+
unit: '$/kg',
|
|
1493
|
+
formula: 'SUM(row.amount * ing.cost) / SUM(row.amount)',
|
|
1494
|
+
description: 'Weighted-average cost per kg. Needs ing.cost (priced in the ingredient\'s own cost unit — typically $/kg).',
|
|
1495
|
+
},
|
|
1496
|
+
{
|
|
1497
|
+
group: 'General',
|
|
1498
|
+
name: 'Ingredient count',
|
|
1499
|
+
unit: '',
|
|
1500
|
+
formula: 'COUNT()',
|
|
1501
|
+
description: 'How many composition rows this formula has.',
|
|
1502
|
+
},
|
|
1503
|
+
{
|
|
1504
|
+
group: 'General',
|
|
1505
|
+
name: 'Resin count',
|
|
1506
|
+
unit: '',
|
|
1507
|
+
formula: "COUNT(WHERE ing.class == 'Resin')",
|
|
1508
|
+
description: 'How many rows are classified as resin (needs ing.class).',
|
|
1509
|
+
},
|
|
1510
|
+
];
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
// Picker modal — group by category, click to add.
|
|
1514
|
+
function openTheoreticalParamPresets(entityType, entityId) {
|
|
1515
|
+
const presets = _tpPresetCatalog();
|
|
1516
|
+
const groups = {};
|
|
1517
|
+
presets.forEach(p => { (groups[p.group || 'Other'] = groups[p.group || 'Other'] || []).push(p); });
|
|
1518
|
+
const presetsJson = JSON.stringify(presets).replace(/'/g, ''');
|
|
1519
|
+
const html = Object.entries(groups).map(([g, items]) => `
|
|
1520
|
+
<div style="margin-bottom:14px">
|
|
1521
|
+
<div style="font-size:11px;font-weight:700;color:var(--text-muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px">${esc(g)}</div>
|
|
1522
|
+
<div style="display:flex;flex-direction:column;gap:6px">
|
|
1523
|
+
${items.map((p, idx) => {
|
|
1524
|
+
const globalIdx = presets.indexOf(p);
|
|
1525
|
+
return `<button type="button" class="btn btn-secondary" style="display:flex;flex-direction:column;align-items:flex-start;text-align:left;padding:10px 14px;width:100%;gap:4px;white-space:normal;line-height:1.4"
|
|
1526
|
+
onclick="_tpAddPresetByIndex('${entityType}','${entityId}',${globalIdx})">
|
|
1527
|
+
<div style="font-weight:600;font-size:13.5px">${esc(p.name)}${p.unit ? ` <span style="font-weight:400;color:var(--text-muted)">(${esc(p.unit)})</span>` : ''}</div>
|
|
1528
|
+
<div style="font-family:ui-monospace,monospace;font-size:11px;color:#475569;word-break:break-all">${esc(p.formula)}</div>
|
|
1529
|
+
${p.description ? `<div style="font-size:11.5px;color:var(--text-muted)">${esc(p.description)}</div>` : ''}
|
|
1530
|
+
</button>`;
|
|
1531
|
+
}).join('')}
|
|
1532
|
+
</div>
|
|
1533
|
+
</div>
|
|
1534
|
+
`).join('');
|
|
1535
|
+
showModal('Pick a preset', `
|
|
1536
|
+
<div style="margin-bottom:10px;font-size:12.5px;color:var(--text-muted)">
|
|
1537
|
+
Click a preset to add it to this ${entityType === 'batch' ? 'batch' : 'formula'}. You can edit the formula afterwards.
|
|
1538
|
+
</div>
|
|
1539
|
+
${html}
|
|
1540
|
+
`, () => false); // No submit — buttons close+add directly
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
function _tpAddPresetByIndex(entityType, entityId, idx) {
|
|
1544
|
+
const presets = _tpPresetCatalog();
|
|
1545
|
+
const p = presets[idx];
|
|
1546
|
+
if (!p) return;
|
|
1547
|
+
addTheoreticalParam(entityType, entityId, {
|
|
1548
|
+
name: p.name,
|
|
1549
|
+
formula: p.formula,
|
|
1550
|
+
unit: p.unit || '',
|
|
1551
|
+
description: p.description || '',
|
|
1552
|
+
spec: null,
|
|
1553
|
+
});
|
|
1554
|
+
// Close the preset modal and re-render the detail page
|
|
1555
|
+
if (typeof closeModal === 'function') closeModal();
|
|
1556
|
+
else document.querySelector('.modal-backdrop')?.remove();
|
|
1557
|
+
if (entityType === 'batch') renderBatchDetail(entityId);
|
|
1558
|
+
else renderFormulationDetail(entityId);
|
|
1559
|
+
}
|