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.