PageSourceSearch

https://www.cc-oulchylechateau.fr/wp-content/plugins/hob-formchat-pro/assets/js/hob-calculator.js?ver=1.29

js cc-oulchylechateau.fr collected 2026-10-02 21:06:34 UTC 14,927 bytes, 444 lines download raw bytes

1/**
2 * HOB FormChat Pro — Calculator Engine (real-time formula evaluation)
3 *
4 * Supports:
5 * - Field references: {field_id} → value of any numeric/amount/calculator field
6 * - Operators: + - * / % ( )
7 * - Functions: min(a,b), max(a,b), round(x), round(x,n), ceil(x), floor(x), abs(x), pow(x,n), sqrt(x)
8 * - Constants: PI, E
9 * - Chained calculators: a calculator can reference another calculator's result
10 * - Formats: number, currency (€/$), percentage (%)
11 *
12 * Auto-trigger system:
13 * - Each calculator listens on ALL its source fields (input + change events)
14 * - When a calculator's result changes, it dispatches an event on its hidden input
15 * - Other calculators referencing that calculator pick up the change → cascade
16 * - Topological sort ensures correct evaluation order even for N-level chains
17 *
18 * @package HOB_FormChat_Pro
19 * @since   1.19
20 */
21(function () {
22    'use strict';
23
24    var calculators = [];
25
26    /** Map of fieldId → array of calculator objects that depend on it */
27    var dependencyMap = {};
28
29    /**
30     * Parse all calculator fields, build dependency map, and set up listeners.
31     */
32    function init() {
33        var wrappers = document.querySelectorAll('.hob-fc-calculator-wrapper');
34        if (!wrappers.length) return;
35
36        calculators = [];
37        dependencyMap = {};
38
39        wrappers.forEach(function (wrapper) {
40            var calc = {
41                wrapper: wrapper,
42                fieldId: wrapper.getAttribute('data-calc-field-id') || '',
43                formula: wrapper.getAttribute('data-calc-formula') || '',
44                sources: (wrapper.getAttribute('data-calc-sources') || '').split(',').map(function (s) { return s.trim(); }).filter(Boolean),
45                format: wrapper.getAttribute('data-calc-format') || 'number',
46                decimals: parseInt(wrapper.getAttribute('data-calc-decimals'), 10) || 2,
47                prefix: wrapper.getAttribute('data-calc-prefix') || '',
48                suffix: wrapper.getAttribute('data-calc-suffix') || '',
49                fallback: wrapper.getAttribute('data-calc-fallback') || '0',
50                realtime: wrapper.getAttribute('data-calc-realtime') !== '0',
51                displayEl: document.getElementById('hob-calc-display-' + wrapper.getAttribute('data-calc-field-id')),
52                inputEl: document.getElementById('hob-calc-input-' + wrapper.getAttribute('data-calc-field-id')),
53                lastValue: null
54            };
55
56            // Also extract field references from formula (auto-detect).
57            var refs = calc.formula.match(/\{([^}]+)\}/g);
58            if (refs) {
59                refs.forEach(function (ref) {
60                    var id = ref.replace(/[{}]/g, '');
61                    if (calc.sources.indexOf(id) === -1) {
62                        calc.sources.push(id);
63                    }
64                });
65            }
66
67            calculators.push(calc);
68        });
69
70        // Build dependency map: for each source field, which calculators depend on it?
71        buildDependencyMap();
72
73        // Bind input listeners on all source fields.
74        calculators.forEach(function (calc) {
75            if (!calc.realtime) return;
76
77            calc.sources.forEach(function (srcId) {
78                var inputs = document.querySelectorAll(
79                    '[name="hob_fields[' + srcId + ']"], ' +
80                    '[name="hob_fields[' + srcId + '][]"], ' +
81                    '#hob-calc-input-' + srcId
82                );
83                inputs.forEach(function (input) {
84                    // Use a debounced handler to avoid excessive recalculations
85                    input.addEventListener('input', debouncedEvaluate);
86                    input.addEventListener('change', debouncedEvaluate);
87                });
88            });
89        });
90
91        // Initial evaluation.
92        evaluateAll();
93    }
94
95    /**
96     * Build the dependency map for cascade triggers.
97     */
98    function buildDependencyMap() {
99        dependencyMap = {};
100        calculators.forEach(function (calc) {
101            calc.sources.forEach(function (srcId) {
102                if (!dependencyMap[srcId]) {
103                    dependencyMap[srcId] = [];
104                }
105                dependencyMap[srcId].push(calc);
106            });
107        });
108    }
109
110    /**
111     * Debounced evaluate — waits 16ms to batch rapid changes.
112     */
113    var evaluateTimer = null;
114    function debouncedEvaluate() {
115        if (evaluateTimer) return;
116        evaluateTimer = setTimeout(function () {
117            evaluateTimer = null;
118            evaluateAll();
119        }, 16);
120    }
121
122    /**
123     * Evaluate all calculators in topological order.
124     * After evaluation, dispatch change events on modified calculators
125     * so that chained calculators pick up the changes.
126     */
127    function evaluateAll() {
128        var sorted = topologicalSort(calculators);
129        var changed = [];
130
131        sorted.forEach(function (calc) {
132            var oldVal = calc.lastValue;
133            evaluateOne(calc);
134            if (calc.lastValue !== oldVal) {
135                changed.push(calc);
136            }
137        });
138
139        // Dispatch change events for modified calculator hidden inputs.
140        // This ensures any non-calculator listeners (e.g. payment fields) also update.
141        changed.forEach(function (calc) {
142            if (calc.inputEl) {
143                calc.inputEl.dispatchEvent(new Event('change', { bubbles: true }));
144            }
145        });
146    }
147
148    /**
149     * Topological sort for calculator dependencies.
150     * Uses DFS with cycle detection to prevent infinite loops.
151     */
152    function topologicalSort(calcs) {
153        var calcMap = {};
154        calcs.forEach(function (c) { calcMap[c.fieldId] = c; });
155
156        var visited = {};
157        var visiting = {}; // For cycle detection
158        var result = [];
159
160        function visit(calc) {
161            if (visited[calc.fieldId]) return;
162            if (visiting[calc.fieldId]) return; // Cycle detected — skip
163
164            visiting[calc.fieldId] = true;
165
166            calc.sources.forEach(function (srcId) {
167                if (calcMap[srcId]) {
168                    visit(calcMap[srcId]);
169                }
170            });
171
172            delete visiting[calc.fieldId];
173            visited[calc.fieldId] = true;
174            result.push(calc);
175        }
176
177        calcs.forEach(function (c) { visit(c); });
178        return result;
179    }
180
181    /**
182     * Evaluate a single calculator.
183     */
184    function evaluateOne(calc) {
185        var formula = calc.formula;
186        if (!formula) return;
187
188        // Replace field references with their values.
189        var resolved = formula.replace(/\{([^}]+)\}/g, function (match, fieldId) {
190            return getFieldValue(fieldId);
191        });
192
193        // Evaluate the expression safely.
194        var result;
195        try {
196            result = safeEval(resolved);
197        } catch (e) {
198            result = parseFloat(calc.fallback) || 0;
199        }
200
201        if (isNaN(result) || !isFinite(result)) {
202            result = parseFloat(calc.fallback) || 0;
203        }
204
205        // Store for chain detection
206        calc.lastValue = result;
207
208        // Format result.
209        var formatted = formatResult(result, calc);
210
211        // Update display and hidden input.
212        if (calc.displayEl) {
213            calc.displayEl.textContent = formatted;
214        }
215        if (calc.inputEl) {
216            calc.inputEl.value = result;
217            // Note: we do NOT dispatch 'input' here to avoid infinite loops.
218            // The evaluateAll() function handles cascade via topological sort.
219        }
220    }
221
222    /**
223     * Get the numeric value of a field by its ID.
224     * Checks calculator hidden inputs first (for chaining), then regular form fields.
225     */
226    function getFieldValue(fieldId) {
227        // Check for calculator hidden input first.
228        var calcInput = document.getElementById('hob-calc-input-' + fieldId);
229        if (calcInput) {
230            return parseFloat(calcInput.value) || 0;
231        }
232
233        // Regular field.
234        var input = document.querySelector('[name="hob_fields[' + fieldId + ']"]');
235        if (!input) {
236            // Try array-style name (checkboxes)
237            var checkboxes = document.querySelectorAll('[name="hob_fields[' + fieldId + '][]"]');
238            if (checkboxes.length) {
239                var sum = 0;
240                checkboxes.forEach(function (cb) {
241                    if (cb.checked) sum += parseFloat(cb.value) || 0;
242                });
243                return sum;
244            }
245            return 0;
246        }
247
248        // Handle radio groups.
249        if (input.type === 'radio') {
250            var checked = document.querySelector('[name="hob_fields[' + fieldId + ']"]:checked');
251            return checked ? (parseFloat(checked.value) || 0) : 0;
252        }
253        // Handle single checkbox.
254        if (input.type === 'checkbox') {
255            var checkedBoxes = document.querySelectorAll('[name="hob_fields[' + fieldId + ']"]:checked, [name="hob_fields[' + fieldId + '][]"]:checked');
256            var total = 0;
257            checkedBoxes.forEach(function (cb) { total += parseFloat(cb.value) || 0; });
258            return total;
259        }
260        // Handle range/slider.
261        if (input.type === 'range') {
262            return parseFloat(input.value) || 0;
263        }
264
265        return parseFloat(input.value) || 0;
266    }
267
268    /**
269     * Safe expression evaluator (no eval).
270     * Supports: + - * / % ( ) and functions min, max, round, ceil, floor, abs, pow, sqrt.
271     */
272    function safeEval(expr) {
273        // Replace constants.
274        expr = expr.replace(/\bPI\b/g, Math.PI.toString());
275        expr = expr.replace(/\bE\b/g, Math.E.toString());
276
277        return parseExpression(expr.trim());
278    }
279
280    // ── Recursive Descent Parser ──────────────────────────────
281
282    var pos, ch, exprStr;
283
284    function parseExpression(str) {
285        exprStr = str;
286        pos = 0;
287        ch = exprStr.charAt(0);
288        var result = parseAddSub();
289        return result;
290    }
291
292    function nextChar() {
293        pos++;
294        ch = pos < exprStr.length ? exprStr.charAt(pos) : '';
295    }
296
297    function skipSpaces() {
298        while (ch === ' ' || ch === '\t') nextChar();
299    }
300
301    function parseAddSub() {
302        var left = parseMulDiv();
303        skipSpaces();
304        while (ch === '+' || ch === '-') {
305            var op = ch;
306            nextChar();
307            var right = parseMulDiv();
308            if (op === '+') left += right;
309            else left -= right;
310            skipSpaces();
311        }
312        return left;
313    }
314
315    function parseMulDiv() {
316        var left = parseUnary();
317        skipSpaces();
318        while (ch === '*' || ch === '/' || ch === '%') {
319            var op = ch;
320            nextChar();
321            var right = parseUnary();
322            if (op === '*') left *= right;
323            else if (op === '/') left = right !== 0 ? left / right : 0;
324            else left = right !== 0 ? left % right : 0;
325            skipSpaces();
326        }
327        return left;
328    }
329
330    function parseUnary() {
331        skipSpaces();
332        if (ch === '-') {
333            nextChar();
334            return -parseAtom();
335        }
336        if (ch === '+') {
337            nextChar();
338        }
339        return parseAtom();
340    }
341
342    function parseAtom() {
343        skipSpaces();
344
345        // Parentheses.
346        if (ch === '(') {
347            nextChar();
348            var val = parseAddSub();
349            if (ch === ')') nextChar();
350            return val;
351        }
352
353        // Function call.
354        var funcMatch = exprStr.substr(pos).match(/^([a-z]+)\s*\(/i);
355        if (funcMatch) {
356            var funcName = funcMatch[1].toLowerCase();
357            pos += funcMatch[0].length;
358            ch = exprStr.charAt(pos);
359            var args = parseFuncArgs();
360            if (ch === ')') nextChar();
361            return applyFunc(funcName, args);
362        }
363
364        // Number.
365        var numStr = '';
366        while ((ch >= '0' && ch <= '9') || ch === '.') {
367            numStr += ch;
368            nextChar();
369        }
370        if (numStr === '') return 0;
371        return parseFloat(numStr);
372    }
373
374    function parseFuncArgs() {
375        var args = [];
376        skipSpaces();
377        if (ch === ')') return args;
378        args.push(parseAddSub());
379        skipSpaces();
380        while (ch === ',') {
381            nextChar();
382            args.push(parseAddSub());
383            skipSpaces();
384        }
385        return args;
386    }
387
388    function applyFunc(name, args) {
389        switch (name) {
390            case 'min': return Math.min.apply(null, args);
391            case 'max': return Math.max.apply(null, args);
392            case 'round':
393                if (args.length >= 2) {
394                    var factor = Math.pow(10, args[1]);
395                    return Math.round(args[0] * factor) / factor;
396                }
397                return Math.round(args[0] || 0);
398            case 'ceil': return Math.ceil(args[0] || 0);
399            case 'floor': return Math.floor(args[0] || 0);
400            case 'abs': return Math.abs(args[0] || 0);
401            case 'pow': return Math.pow(args[0] || 0, args[1] || 0);
402            case 'sqrt': return Math.sqrt(args[0] || 0);
403            default: return args[0] || 0;
404        }
405    }
406
407    /**
408     * Format the result based on calculator config.
409     */
410    function formatResult(value, calc) {
411        var dec = calc.decimals;
412        var formatted;
413
414        if (calc.format === 'currency') {
415            // French format: 1 234,56
416            formatted = value.toFixed(dec).replace('.', ',').replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
417        } else if (calc.format === 'percent') {
418            formatted = value.toFixed(dec).replace('.', ',');
419        } else {
420            formatted = value.toFixed(dec);
421            // French formatting if locale is fr.
422            if (document.documentElement.lang && document.documentElement.lang.startsWith('fr')) {
423                formatted = formatted.replace('.', ',');
424            }
425        }
426
427        return formatted;
428    }
429
430    // ── Init ──
431
432    if (document.readyState === 'loading') {
433        document.addEventListener('DOMContentLoaded', init);
434    } else {
435        init();
436    }
437
438    // Expose for external use.
439    window.HobCalculator = {
440        init: init,
441        evaluateAll: evaluateAll,
442        getFieldValue: getFieldValue
443    };
444})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.