formlab-mcp 0.6.49 → 0.6.51

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.
@@ -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(' &nbsp;·&nbsp; ');
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(' &nbsp;·&nbsp; ')}</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 &amp; 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, '&#39;');
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
+ }