PageSourceSearch

https://www.betting-forum.com/js/iceto/oddstools/oddstools.js?_v=c2657d5c

js betting-forum.com collected 2026-09-24 09:11:15 UTC 111,666 bytes, 2,980 lines download raw bytes

1// v3.4.70 JS cache bust
2
3
4/* Iceto OddsTools - client UI
5 * Works without template modifications by injecting a FAB + loading this file.
6 */
7(function(){
8  const W = window;
9  const CFG = (W.IcetoOddsTools && W.IcetoOddsTools.config) || {};
10  if (!CFG || !CFG.enabled) return;
11
12  // Mobile polish: track the usable viewport height so the popup panel can
13  // "snap" to it (address bar + keyboard can change the effective height).
14  let icetoViewportVarBound = false;
15  function icetoUpdateViewportVar(){
16    try {
17      const h = (W.visualViewport && W.visualViewport.height) ? W.visualViewport.height : W.innerHeight;
18      document.documentElement.style.setProperty('--iceto-ot-viewportH', Math.round(h) + 'px');
19    } catch (e) {}
20  }
21  function icetoBindViewportVar(){
22    if (icetoViewportVarBound) return;
23    icetoViewportVarBound = true;
24    icetoUpdateViewportVar();
25    if (W.visualViewport && W.visualViewport.addEventListener){
26      W.visualViewport.addEventListener('resize', icetoUpdateViewportVar, {passive:true});
27      W.visualViewport.addEventListener('scroll', icetoUpdateViewportVar, {passive:true});
28    }
29    W.addEventListener('resize', icetoUpdateViewportVar, {passive:true});
30    W.addEventListener('orientationchange', icetoUpdateViewportVar, {passive:true});
31  }
32
33  // Mark current color scheme for the widget (used by CSS for dark mode).
34  // This avoids relying on theme-specific classes and keeps light mode untouched.
35  function applySchemeMarker(){
36    try {
37      // XenForo sometimes uses a light wrapper background even in dark styles.
38      // We therefore prioritize the real page canvas (html/body) and also consider text color.
39      const elHtml = document.documentElement;
40      const elBody = document.body;
41
42      // If XenForo exposes a scheme attribute/class, trust it.
43      const attrCandidates = ['data-color-scheme','data-xf-color-scheme','data-theme','data-scheme'];
44      for (const a of attrCandidates){
45        const v = elHtml.getAttribute(a) || elBody.getAttribute(a);
46        if (v && /dark/i.test(v)){
47          elHtml.setAttribute('data-iceto-ot-scheme','dark');
48          return;
49        }
50        if (v && /light/i.test(v)){
51          elHtml.setAttribute('data-iceto-ot-scheme','light');
52          return;
53        }
54      }
55      const classStr = (elHtml.className || '') + ' ' + (elBody.className || '');
56      if (/\bdark\b/i.test(classStr) && !/\blight\b/i.test(classStr)){
57        elHtml.setAttribute('data-iceto-ot-scheme','dark');
58        return;
59      }
60
61      const parseRGBA = (bg) => {
62        const mm = (bg || '').match(/rgba?\((\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d\.]+))?\)/i);
63        if (!mm) return null;
64        const a = (mm[4] === undefined) ? 1 : parseFloat(mm[4]);
65        return { r: parseInt(mm[1],10), g: parseInt(mm[2],10), b: parseInt(mm[3],10), a };
66      };
67
68      const lumOf = (rgb) => (0.2126*rgb.r + 0.7152*rgb.g + 0.0722*rgb.b) / 255;
69
70      // Background sampling - prefer html/body; otherwise fall back to XF wrappers.
71      const candidates = [
72        document.querySelector('.block-container'),
73        document.querySelector('.p-body-inner'),
74        document.querySelector('.p-body'),
75        document.querySelector('.p-pageWrapper'),
76        elBody,
77        elHtml
78      ].filter(Boolean);
79
80      let bgLum = null;
81
82      for (const el of candidates){
83        const st = getComputedStyle(el);
84        const bg = parseRGBA(st.backgroundColor);
85        if (!bg || bg.a <= 0.02) continue;
86        bgLum = lumOf(bg);
87        // if html/body provides a solid bg, stop there
88        if (el === elHtml || el === elBody) break;
89      }
90
91      // Text sampling - dark styles generally have light text.
92      let textLum = null;
93      try {
94        const st = getComputedStyle(document.querySelector('.p-body') || elBody || elHtml);
95        const c = parseRGBA(st.color);
96        if (c) textLum = lumOf(c);
97      } catch(e){}
98
99      // Decide scheme:
100      // - If background is known, trust it.
101      // - Otherwise infer from text color (light text -> dark scheme).
102      let scheme = 'light';
103      if (bgLum !== null){
104        scheme = (bgLum < 0.45) ? 'dark' : 'light';
105      } else if (textLum !== null){
106        scheme = (textLum > 0.65) ? 'dark' : 'light';
107      }
108
109      elHtml.setAttribute('data-iceto-ot-scheme', scheme);
110    } catch(e){}
111  }
112
113  applySchemeMarker();
114  // Keep scheme marker in sync when the user switches styles without a full reload.
115  // (Some styles toggle via attribute/class changes or stylesheet swaps.)
116  function scheduleSchemeUpdate(){
117    if (scheduleSchemeUpdate._t) return;
118    scheduleSchemeUpdate._t = W.setTimeout(function(){
119      scheduleSchemeUpdate._t = 0;
120      applySchemeMarker();
121    }, 60);
122  }
123
124  // Watch for theme/style changes on <html>/<body>.
125  try {
126    const schemeAttrs = ['class','style','data-color-scheme','data-xf-color-scheme','data-theme','data-scheme'];
127    const mo = new MutationObserver(scheduleSchemeUpdate);
128    mo.observe(document.documentElement, { attributes: true, attributeFilter: schemeAttrs });
129    if (document.body){
130      mo.observe(document.body, { attributes: true, attributeFilter: schemeAttrs });
131    }
132  } catch(e){}
133
134  // Watch for stylesheet changes (some style switchers swap CSS files).
135  try {
136    const head = document.head;
137    if (head){
138      const moHead = new MutationObserver(function(muts){
139        for (const m of muts){
140          if (!m.addedNodes) continue;
141          for (const n of m.addedNodes){
142            if (!n || !n.tagName) continue;
143            const t = n.tagName.toUpperCase();
144            if (t === 'LINK' || t === 'STYLE'){
145              scheduleSchemeUpdate();
146              return;
147            }
148          }
149        }
150      });
151      moHead.observe(head, { childList: true, subtree: true });
152    }
153  } catch(e){}
154
155  // OS-level scheme changes (rare, but free).
156  try {
157    const mq = W.matchMedia && W.matchMedia('(prefers-color-scheme: dark)');
158    if (mq && mq.addEventListener){
159      mq.addEventListener('change', scheduleSchemeUpdate);
160    } else if (mq && mq.addListener){
161      mq.addListener(scheduleSchemeUpdate);
162    }
163  } catch(e){}
164
165  // XenForo re-init hooks (when available).
166  try {
167    document.addEventListener('xf:load', scheduleSchemeUpdate);
168    document.addEventListener('xf:reinit', scheduleSchemeUpdate);
169    document.addEventListener('visibilitychange', scheduleSchemeUpdate);
170    W.addEventListener('focus', scheduleSchemeUpdate);
171  } catch(e){}
172
173  const qs = (sel, root=document)=>root.querySelector(sel);
174  const qsa = (sel, root=document)=>Array.from(root.querySelectorAll(sel));
175
176  const TAB_DEFS = [
177    { id:'odds',   label:'Odds',    render: renderOdds },
178    { id:'margin', label:'Margin',  render: renderMargin },
179    { id:'arb',    label:'Arb',     render: renderArb },
180    { id:'parlay', label:'Parlay',  render: renderParlay },
181    { id:'kelly',  label:'Kelly',   render: renderKelly },
182    { id:'value',  label:'Value',   render: renderValue },
183    { id:'hedge',  label:'Hedge',   render: renderHedge },
184    { id:'dutch',  label:'Dutch',   render: renderDutch },
185    { id:'lay',    label:'Lay',     render: renderLay },
186    { id:'freebet',label:'Free bet',render: renderFreebet },
187  ];
188
189  const TAB_MAP = Object.fromEntries(TAB_DEFS.map(t=>[t.id,t]));
190
191  // Track active tab for copy-line (outer-scope so the handler can read it).
192  let ACTIVE_TAB_ID = null;
193
194  function clamp(n, a, b){ return Math.min(b, Math.max(a, n)); }
195
196  // --- Lightweight undo support for paste-normalized fields
197  // We sanitize pasted text (and sometimes normalize after paste), which can break
198  // native browser undo stacks. This keeps Ctrl/Cmd+Z working for paste actions.
199  const _undoStacks = new WeakMap();
200  function pushUndo(el){
201    if (!el) return;
202    let st = _undoStacks.get(el);
203    if (!st){
204      st = [];
205      _undoStacks.set(el, st);
206    }
207    const v = String(el.value ?? '');
208    if (!st.length || st[st.length - 1] !== v){
209      st.push(v);
210      if (st.length > 40) st.shift();
211    }
212  }
213  function popUndo(el){
214    const st = _undoStacks.get(el);
215    if (st && st.length) return st.pop();
216    return null;
217  }
218
219// Normalize locale formatted numbers:
220// - "1,91" -> "1.91"
221// - "1.000,50" -> "1000.50"
222// - "1,000.50" -> "1000.50"
223// Also strips spaces and non-number junk.
224function normalizeLocaleNumberString(raw){
225  let s = String(raw ?? '').trim();
226  if (!s) return '';
227
228  // keep fractions as-is
229  if (/^\d+\s*\/\s*\d+$/.test(s)){
230    return s.replace(/\s+/g,'');
231  }
232
233  // remove spaces
234  s = s.replace(/\s+/g,'');
235
236  // capture sign
237  let sign = '';
238  if (s[0] === '+' || s[0] === '-'){
239    sign = s[0];
240    s = s.slice(1);
241  }
242
243  // keep only digits and separators
244  s = s.replace(/[^0-9\.,]/g,'');
245  if (!s) return sign ? sign : '';
246
247  const hasDot = s.indexOf('.') !== -1;
248  const hasComma = s.indexOf(',') !== -1;
249
250  const isDigits = (x)=>/^\d+$/.test(x);
251  const looksGrouped = (arr)=>arr.length > 1 && isDigits(arr[0]) && arr.slice(1).every(p=>isDigits(p) && p.length === 3);
252
253  if (hasDot && hasComma){
254    const lastDot = s.lastIndexOf('.');
255    const lastComma = s.lastIndexOf(',');
256    if (lastDot > lastComma){
257      // dot decimal, comma thousands
258      s = s.replace(/,/g,'');
259    } else {
260      // comma decimal, dot thousands
261      s = s.replace(/\./g,'');
262      s = s.replace(/,/g,'.');
263    }
264  } else if (hasComma){
265    const arr = s.split(',');
266    if (looksGrouped(arr)){
267      // thousands grouping
268      s = arr.join('');
269    } else {
270      // treat last comma as decimal separator
271      const last = s.lastIndexOf(',');
272      s = s.slice(0,last).replace(/,/g,'') + '.' + s.slice(last+1);
273    }
274  } else if (hasDot){
275    const arr = s.split('.');
276    if (looksGrouped(arr)){
277      s = arr.join('');
278    } else if (arr.length > 2){
279      // keep last dot as decimal, others as thousands
280      const last = s.lastIndexOf('.');
281      s = s.slice(0,last).replace(/\./g,'') + '.' + s.slice(last+1);
282    }
283  }
284
285  // collapse multiple dots if any remain
286  if ((s.match(/\./g) || []).length > 1){
287    const last = s.lastIndexOf('.');
288    s = s.slice(0,last).replace(/\./g,'') + '.' + s.slice(last+1);
289  }
290
291  if (s.startsWith('.')) s = '0' + s;
292  if (s.endsWith('.')) s = s.slice(0,-1);
293
294  return sign + s;
295}
296
297function num(v){
298  if (v === null || v === undefined) return NaN;
299  const s = normalizeLocaleNumberString(v);
300  if (!s || s === '+' || s === '-') return NaN;
301  const n = parseFloat(s);
302  return Number.isFinite(n) ? n : NaN;
303}
304
305function fmtDecClean(dec, maxDp=3){
306  if (!Number.isFinite(dec)) return '';
307  let s = dec.toFixed(maxDp);
308  s = s.replace(/(\.\d*?)0+$/,'$1').replace(/\.$/,'');
309  return s;
310}
311
312function normalizeOddsListSeparators(text){
313  let s = String(text || '');
314  // Normalize common Unicode minus/dash characters that appear in copied odds lines.
315  s = s.replace(/[\u2212\u2013\u2014]/g, '-');
316  // Replace common bullet characters with spaces.
317  s = s.replace(/[\u2022\u00B7•]/g, ' ');
318  let out = '';
319  for (let i = 0; i < s.length; i++){
320    const ch = s[i];
321    const prev = i > 0 ? s[i-1] : '';
322    const next = (i + 1 < s.length) ? s[i+1] : '';
323
324    if (ch === ','){
325      const prevIsDigit = prev >= '0' && prev <= '9';
326      const nextIsDigit = next >= '0' && next <= '9';
327      // Keep comma inside numbers (decimal comma), but treat other commas as separators.
328      out += (prevIsDigit && nextIsDigit) ? ',' : ' ';
329      continue;
330    }
331
332    // Common separators in pasted lines
333    if (ch === ';' || ch === '|' || ch === '\n' || ch === '\r' || ch === '\t'){
334      out += ' ';
335      continue;
336    }
337
338    out += ch;
339  }
340  return out;
341}
342
343function parseOddsListFromText(text, max=20){
344  // For Parlay/Dutch list inputs:
345  // - preserve the original order (mixed decimal / American / fractional)
346  // - allow decimal commas like "1,91"
347  // - treat comma/semicolon/newline as list separators
348  // - allow separators like "x" / "×" in pasted parlay lines
349  const base = normalizeOddsListSeparators(text);
350
351  // Convert common multipliers into separators (only when used as a separator)
352  const cleaned = base
353    .replace(/(\d)\s*[x×]\s*(\d)/gi, '$1 $2')
354    .replace(/\b[x×]\b/gi, ' ');
355
356  const out = [];
357  // Note: include 10-19 (e.g. 11.00 / 12.00) as valid decimal odds tokens.
358  // Also avoid accidentally capturing times like "12:30".
359  const re = /(\d+\s*\/\s*\d+|[+-]\s?\d{2,5}|[1-9]\d?[\.,]\d{1,3}|(?:100|[2-9]\d?|1\d))(?!\d)(?!\s*:\s*\d)/g;
360
361  let m;
362  while ((m = re.exec(cleaned)) !== null){
363    const token = (m[1] || '').trim();
364    if (!token) continue;
365
366    let dec = NaN;
367
368    if (token.includes('/')){
369      dec = fracToDec(token);
370    } else if (token[0] === '+' || token[0] === '-'){
371      const a = num(token.replace(/\s+/g,''));
372      if (Number.isFinite(a) && Math.abs(a) >= 100 && Math.abs(a) <= 5000){
373        dec = americanToDec(a);
374      }
375    } else {
376      dec = num(token);
377    }
378
379    if (Number.isFinite(dec) && dec > 1 && dec <= 100){
380      out.push(dec);
381      if (out.length >= max) break;
382    }
383  }
384
385  return out;
386}
387
388// Find the first odds-like token in a messy string (decimal / American / fractional).
389// Returns { kind, token, index, decimal, decimalStr } or null.
390function findFirstOddsToken(text){
391  const s = String(text ?? '');
392  if (!s) return null;
393
394  let best = null;
395
396  function fmtDec(dec){
397    if (!Number.isFinite(dec)) return '';
398    // up to 4dp, trimmed
399    const s = dec.toFixed(4).replace(/0+$/,'').replace(/\.$/,'');
400    return s;
401  }
402
403  function consider(kind, token, index){
404    if (index == null || index < 0) return;
405    const dec = oddsToDec(token);
406    if (!Number.isFinite(dec) || dec <= 1 || dec > 1000) return;
407    const obj = {
408      kind,
409      token: String(token).trim(),
410      index,
411      decimal: dec,
412      decimalStr: fmtDec(dec)
413    };
414    if (!best || obj.index < best.index){
415      best = obj;
416    }
417  }
418
419  // American (sign required)
420  {
421    const re = /[+-]\s*\d{2,5}/g;
422    const m = re.exec(s);
423    if (m) consider('american', m[0], m.index);
424  }
425
426  // Fractional
427  {
428    const re = /\b\d+\s*\/\s*\d+\b/g;
429    const m = re.exec(s);
430    if (m) consider('fractional', m[0], m.index);
431  }
432
433  // Decimal / integers
434  {
435    const re = /\b\d+(?:[\.,]\d{1,4})?\b/g;
436    let m;
437    while ((m = re.exec(s))){
438      const tok = m[0];
439      const dec = oddsToDec(tok);
440      if (Number.isFinite(dec) && dec > 1 && dec <= 1000){
441        consider('decimal', tok, m.index);
442        break; // first valid decimal token in reading order
443      }
444    }
445  }
446
447  return best;
448}
449
450function formatOddsForConverterField(info, fieldOt){
451  if (!info) return '';
452  const ot = String(fieldOt || '').toLowerCase();
453
454  if (ot === 'dec'){
455    return info.kind === 'decimal' ? normalizeLocaleNumberString(info.token) : info.decimalStr;
456  }
457  if (ot === 'am'){
458    if (info.kind === 'american'){
459      return info.token.replace(/\s+/g,'');
460    }
461    const a = decToAmerican(info.decimal);
462    if (!Number.isFinite(a) || a === 0) return '';
463    return (a > 0 ? '+' : '') + String(a);
464  }
465  if (ot === 'frac'){
466    if (info.kind === 'fractional'){
467      return info.token.replace(/\s+/g,'');
468    }
469    return decToFrac(info.decimal);
470  }
471  return info.decimalStr;
472}
473
474// Insert text into an input/textarea in a way that keeps browser undo (Ctrl+Z) working.
475function applyPasteText(el, text){
476  const s = String(text ?? '');
477  try{
478    const start = typeof el.selectionStart === 'number' ? el.selectionStart : (el.value || '').length;
479    const end = typeof el.selectionEnd === 'number' ? el.selectionEnd : (el.value || '').length;
480    if (typeof el.setRangeText === 'function'){
481      el.setRangeText(s, start, end, 'end');
482      return;
483    }
484  } catch (e) {}
485
486  // Fallback - may not preserve undo in some browsers, but better than merging junk.
487  try{
488    document.execCommand('insertText', false, s);
489    return;
490  } catch (e) {}
491
492  el.value = s;
493}
494
495
496
497function normalizeOddsListText(text){
498  const decs = parseOddsListFromText(text);
499  if (!decs.length) return '';
500  return decs.map(d=>fmtDecClean(d,3)).join(', ');
501}
502
503function shouldNormalizeEl(el){
504  if (!el || !el.tagName) return false;
505  const tag = el.tagName.toUpperCase();
506  if (tag === 'INPUT'){
507    // Explicit marker for single-odds inputs (used in Lay/Hedge/Free bet etc.)
508    // Allows smart paste + safe normalization even if data-ot overlaps with other tabs.
509    if (el.getAttribute('data-ot-odds') === '1') return true;
510    const ot = (el.getAttribute('data-ot') || '').toLowerCase();
511    if (ot === 'frac') return false;
512    // Arb odds fields support smart paste (eg: "Home @ 1.91 Draw 3.40 Away 4.50").
513    // If we normalize the whole pasted string, multiple decimals can get merged
514    // into one huge number (eg: 1913404.50). Skip normalization for Arb odds.
515    if (ot === 'a' || ot === 'b' || ot === 'c' || ot === 'o1' || ot === 'ox' || ot === 'o2') return false;
516    const im = (el.getAttribute('inputmode') || '').toLowerCase();
517    return (im === 'decimal' || im === 'numeric');
518  }
519  if (tag === 'TEXTAREA'){
520    const ot = (el.getAttribute('data-ot') || '').toLowerCase();
521    return (ot === 'legs' || ot === 'odds');
522  }
523  return false;
524}
525
526function normalizeElementValue(el){
527  if (!shouldNormalizeEl(el)) return;
528
529  const tag = el.tagName.toUpperCase();
530  if (tag === 'TEXTAREA'){
531    const ot = (el.getAttribute('data-ot') || '').toLowerCase();
532    if (ot === 'legs' || ot === 'odds'){
533      const norm = normalizeOddsListText(el.value);
534      if (norm) el.value = norm;
535      return;
536    }
537  }
538
539  const raw = String(el.value || '').trim();
540  if (!raw) return;
541
542  // If a user pasted a full odds line (e.g. "Home @ 1.91 Draw 3.40 Away 4.50"),
543  // do NOT strip to digits and accidentally merge into a giant number.
544  if (tag !== 'TEXTAREA'){
545    const ot = (el.getAttribute('data-ot') || '').toLowerCase();
546    const isMarkedOdds = (el.getAttribute('data-ot-odds') === '1');
547    const isOddsField = (ot === 'odds' || ot === 'dec' || ot === 'am' || isMarkedOdds);
548    if (isOddsField){
549      if (/[A-Za-z@]/.test(raw) || /\s/.test(raw)){
550        const info = findFirstOddsToken(raw);
551        if (info && info.decimalStr){
552          if (ot === 'am'){
553            const v = formatOddsForConverterField(info, 'am');
554            if (v) { el.value = v; return; }
555          } else {
556            el.value = info.decimalStr;
557            return;
558          }
559        }
560      }
561    }
562  }
563
564  // keep fractions
565  if (/^\d+\s*\/\s*\d+$/.test(raw)){
566    el.value = raw.replace(/\s+/g,'');
567    return;
568  }
569
570  const norm = normalizeLocaleNumberString(raw);
571  if (norm && norm !== raw){
572    el.value = norm;
573  }
574}
575
576  function decToImp(dec){
577    if (!Number.isFinite(dec) || dec <= 1) return NaN;
578    return 1/dec;
579  }
580  function decToAmerican(dec){
581    if (!Number.isFinite(dec) || dec <= 1) return NaN;
582    const p = 1/dec;
583    // convert from decimal to american
584    if (dec >= 2) return Math.round((dec - 1) * 100);
585    return -Math.round(100 / (dec - 1));
586  }
587  function americanToDec(a){
588    if (!Number.isFinite(a) || a === 0) return NaN;
589    if (a > 0) return 1 + (a/100);
590    return 1 + (100/Math.abs(a));
591  }
592  function fracToDec(fr){
593    const m = String(fr).trim().match(/^(\d+)\s*\/\s*(\d+)$/);
594    if (!m) return NaN;
595    const n = parseInt(m[1],10), d=parseInt(m[2],10);
596    if (!d) return NaN;
597    return 1 + (n/d);
598  }
599
600  // Parse common odds formats into decimal odds.
601  // - Decimal: 1.91 / 2,05 / 2
602  // - American: +110 / -120 (sign required)
603  // - Fractional: 10/11
604  function oddsToDec(raw){
605    const s0 = String(raw ?? '').trim();
606    if (!s0) return NaN;
607
608    // Fractional
609    if (/^\d+\s*\/\s*\d+$/.test(s0)){
610      return fracToDec(s0);
611    }
612
613    // American (sign required)
614    const s = s0.replace(/\u2212/g, '-'); // normalize Unicode minus
615    if (/^[+-]\s*\d{2,5}$/.test(s)){
616      const a = num(s.replace(/\s+/g,''));
617      if (Number.isFinite(a) && Math.abs(a) >= 100 && Math.abs(a) <= 5000){
618        return americanToDec(a);
619      }
620    }
621
622    // Decimal / locale number
623    return num(s0);
624  }
625  function decToFrac(dec){
626    if (!Number.isFinite(dec) || dec <= 1) return '';
627    // approximate to common fractions with denom up to 20
628    const x = dec - 1;
629    let best = {n:1,d:1,err:Infinity};
630    for (let d=1; d<=20; d++){
631      const n = Math.round(x*d);
632      const err = Math.abs(x - (n/d));
633      if (err < best.err){
634        best = {n,d,err};
635      }
636    }
637    return best.n + '/' + best.d;
638  }
639
640  function fmtPct(p){
641    if (!Number.isFinite(p)) return '–';
642    return (p*100).toFixed(2) + '%';
643  }
644  function fmtNum(n, dp=2){
645    if (!Number.isFinite(n)) return '–';
646    return n.toFixed(dp);
647  }
648  function fmtMoney(n){
649    if (!Number.isFinite(n)) return '–';
650    return n.toFixed(2);
651  }
652
653  function setInlineWarn(rootEl, msg){
654    const el = qs('[data-ot="warn"]', rootEl);
655    if (!el) return;
656    if (msg){
657      el.textContent = msg;
658      el.style.display = '';
659    } else {
660      el.textContent = '';
661      el.style.display = 'none';
662    }
663  }
664
665  function hasBadOddsToken(text){
666    const s = String(text || '');
667    // standalone <= 1.00 odds tokens (0.xx, 1, 1.0, 1.00, 1,00 etc.)
668    // IMPORTANT: do NOT flag numbers like 1.91.
669    // We only want: 0.xx, 1, 1.0, 1.00, 1,00 ... (only zeros if a decimal exists).
670    // The extra negative lookahead prevents matching a bare "1" in "1.91".
671    const re = /(^|[^0-9])((?:0(?:[\.,]\d+)?|1(?:[\.,]0+)?))(?![\.,]\d)(?=[^0-9]|$)/;
672    return re.test(s);
673  }
674
675  function isBadStake(n){
676    return Number.isFinite(n) && n < 0;
677  }
678
679
680  function toast(msg){
681    let t = qs('.iceto-ot-toast');
682    if (!t){
683      t = document.createElement('div');
684      t.className = 'iceto-ot-toast';
685      document.body.appendChild(t);
686    }
687    t.textContent = msg;
688    t.classList.add('is-show');
689    clearTimeout(t._to);
690    t._to = setTimeout(()=>
690t.classList.remove('is-show'), 1200);
691  }
692
693  function copyText(txt){
694    if (!txt) return;
695    if (navigator.clipboard && navigator.clipboard.writeText){
696      navigator.clipboard.writeText(txt).then(()=>toast('Copied'));
697      return;
698    }
699    const ta = document.createElement('textarea');
700    ta.value = txt;
701    ta.style.position='fixed';
702    ta.style.left='-9999px';
703    document.body.appendChild(ta);
704    ta.select();
705    try { document.execCommand('copy'); toast('Copied'); } catch(e){}
706    ta.remove();
707  }
708
709  function parseOddsFromText(text){
710    const s = String(text||'');
711    const out = [];
712
713    // decimal like 1.91 / 2.05
714    s.replace(/(^|[^\d])([1-9]\d?[\.,]\d{1,3})(?=[^\d]|$)/g, (_,p,v)=>{
715      const n = num(v);
716      if (Number.isFinite(n) && n >= 1.01 && n <= 100) out.push({type:'dec', value:n});
717      return _;
718    });
719
720    // integer decimal like 2 / 10 / 100 (allow in parlays/dutching etc.)
721    // Avoid matching parts of decimals (2.10) or fraction parts (10/11).
722    s.replace(/(^|[^\d])([2-9]\d?|100)(?=[^\d]|$)/g, (m, p, v, off, str)=>{
723      const prev = p || '';
724      const next = str && typeof off === 'number' ? str.charAt(off + m.length) : '';
725      if (prev === '.' || prev === ',' || prev === '/' || next === '.' || next === ',' || next === '/') return m;
726      const n = num(v);
727      if (Number.isFinite(n) && n >= 1.01 && n <= 100) out.push({type:'dec', value:n});
728      return m;
729    });
730
731    // american +110 / -125
732    s.replace(/(^|[^0-9])([+-]\s?\d{2,5})(?=[^0-9]|$)/g, (_,p,v)=>{
733      const n = num(v.replace(/\s+/g,''));
734      if (Number.isFinite(n) && Math.abs(n) >= 100 && Math.abs(n) <= 5000) out.push({type:'am', value:n});
735      return _;
736    });
737
738    // fractional 10/11
739    s.replace(/(^|[^\d])(\d+\s*\/\s*\d+)(?=[^\d]|$)/g, (_,p,v)=>{
740      const d = fracToDec(v);
741      if (Number.isFinite(d)) out.push({type:'frac', value:v.trim()});
742      return _;
743    });
744
745    // normalize to decimals
746    const decs = out.map(o=>{
747      if (o.type==='dec') return o.value;
748      if (o.type==='am') return americanToDec(o.value);
749      if (o.type==='frac') return fracToDec(o.value);
750      return NaN;
751    }).filter(n=>Number.isFinite(n) && n>1);
752    // keep duplicates (important for parlays/dutching where odds can repeat)
753    // cap to prevent pasted walls of numbers from bogging down the UI
754    return decs.slice(0, 20);
755  }
756
757  // --- UI bootstrap
758  // Dedicated page should work even if the FAB/root injection is missing for any reason.
759  const pageLauncher = qs('[data-iceto-ot-page="1"]');
760  const fab = qs('[data-iceto-ot-fab="1"]');
761  const root = qs('[data-iceto-ot-root="1"]');
762
763  // Track viewport height for mobile popup sizing.
764  icetoBindViewportVar();
765
766  // apply size + offset options (popup FAB only)
767  const size = clamp(num(CFG.buttonSize || '1.0') || 1.0, 0.75, 1.4);
768  const bottom = clamp(parseInt(CFG.bottomOffset||80,10) || 80, 10, 240);
769  if (fab){
770    fab.style.transformOrigin = 'bottom right';
771    fab.style.transform = `scale(${size})`;
772    fab.style.bottom = bottom + 'px';
773  }
774
775  // page mode: if container exists, open as embedded panel
776  if (pageLauncher){
777    // hide FAB on the dedicated page (cleaner)
778    if (fab) fab.style.display = 'none';
779    applySchemeMarker();
780    mountPanel({embedded:true, container: pageLauncher});
781  }
782
783  // If we don't have the popup elements, stop here (page mode already mounted if present).
784  if (!fab || !root) return;
785
786  function openPanel(){
787    if (qs('.iceto-ot-backdrop', root)) return;
788    icetoUpdateViewportVar();
789    applySchemeMarker();
790    mountPanel({embedded:false});
791    fab.setAttribute('aria-expanded','true');
792  }
793
794  function closePanel(){
795    root.innerHTML = '';
796    root.setAttribute('aria-hidden','true');
797    fab.setAttribute('aria-expanded','false');
798    fab.focus({preventScroll:true});
799  }
800
801  function mountPanel({embedded, container}){
802    applySchemeMarker();
803
804    let wrap = null;
805    if (!embedded){
806      if (!root) return;
807      root.setAttribute('aria-hidden', 'false');
808      root.innerHTML = '';
809      wrap = document.createElement('div');
810      wrap.className = 'iceto-ot-rootWrap';
811
812      const backdrop = document.createElement('div');
813      backdrop.className = 'iceto-ot-backdrop';
814      backdrop.addEventListener('click', closePanel);
815      wrap.appendChild(backdrop);
816    } else {
817      // Page mode: keep popup root hidden if it exists, but don't require it.
818      if (root){
819        root.setAttribute('aria-hidden', 'true');
820        root.innerHTML = '';
821      }
822    }
823
824    const panel = document.createElement('div');
825    panel.className = 'iceto-ot-panel';
826    panel.classList.add(embedded ? 'iceto-ot-panel--embedded' : 'iceto-ot-panel--popup');
827    if (embedded){
828      panel.style.position = 'relative';
829      panel.style.right = 'auto';
830      panel.style.bottom = 'auto';
831      panel.style.width = '100%';
832      // In page mode we want natural height (the popup uses a fixed height).
833      panel.style.height = 'auto';
834      panel.style.maxHeight = 'none';
835      panel.style.boxShadow = 'none';
836    } else {
837      panel.style.bottom = (bottom + 58) + 'px';
838    }
839
840    panel.addEventListener('click', (e)=>e.stopPropagation());
841
842    panel.innerHTML = `
843      <div class="iceto-ot-head">
844        <div class="iceto-ot-title">
845          <span>Odds tools</span>
846          <span class="iceto-ot-badge">Quick</span>
847        </div>
848        <div class="iceto-ot-actions">
849          <button type="button" class="iceto-ot-btn" data-ot-action="copy">Copy line</button>
850          <button type="button" class="iceto-ot-btn" data-ot-action="reset">Reset</button>
851          ${embedded ? '' : '<button type="button" class="iceto-ot-btn" data-ot-action="page">Full page</button>'}
852          ${embedded ? '' : '<button type="button" class="iceto-ot-btn" data-ot-action="close">Close</button>'}
853        </div>
854      </div>
855      <div class="iceto-ot-tabs" role="tablist"></div>
856      <div class="iceto-ot-body"></div>
857      <div class="iceto-ot-ads${embedded ? ' iceto-ot-ads--page' : ''}" data-iceto-ot-ads="1">
858          <a href="https://www.betting-forum.com/goto/everygame" rel="nofollow">Everygame</a>
859          <span class="sep">•</span>
860          <a href="https://www.betting-forum.com/goto/1xbet" rel="nofollow">1XBET</a>
861          <span class="sep">•</span>
862          <a href="https://www.betting-forum.com/goto/888sport" rel="nofollow">888 Sport</a>
863        </div>
864    `;
865
866    const tabsEl = qs('.iceto-ot-tabs', panel);
867    const bodyEl = qs('.iceto-ot-body', panel);
868
869    // Mobile tab row polish:
870    // - Fade edges when the tab row is horizontally scrollable
871    // - Auto-scroll the active tab into view (centered) on tap/switch
872    function updateTabsScrollState(){
873      if (!tabsEl) return;
874      const scrollable = (tabsEl.scrollWidth > tabsEl.clientWidth + 2);
875      tabsEl.classList.toggle('iceto-ot-tabs--scrollable', scrollable);
876      if (!scrollable){
877        tabsEl.classList.remove('iceto-ot-tabs--atStart','iceto-ot-tabs--atEnd');
878        return;
879      }
880      const atStart = (tabsEl.scrollLeft <= 1);
881      const atEnd = (tabsEl.scrollLeft + tabsEl.clientWidth) >= (tabsEl.scrollWidth - 1);
882      tabsEl.classList.toggle('iceto-ot-tabs--atStart', atStart);
883      tabsEl.classList.toggle('iceto-ot-tabs--atEnd', atEnd);
884    }
885
886    function scrollActiveTabIntoView(){
887      if (!tabsEl) return;
888      const btn = qs('.iceto-ot-tab.is-active', tabsEl);
889      if (!btn) return;
890      const scrollable = (tabsEl.scrollWidth > tabsEl.clientWidth + 2);
891      if (!scrollable) return;
892
893      const cRect = tabsEl.getBoundingClientRect();
894      const bRect = btn.getBoundingClientRect();
895      const targetCenter = (bRect.left + bRect.width / 2);
896      const containerCenter = (cRect.left + cRect.width / 2);
897
898      // Center the active tab without triggering page scroll.
899      tabsEl.scrollLeft += (targetCenter - containerCenter);
900      updateTabsScrollState();
901    }
902
903    let _tabsScrollRAF = 0;
904    if (tabsEl){
905      tabsEl.addEventListener('scroll', ()=>{
906        if (_tabsScrollRAF) return;
907        _tabsScrollRAF = W.requestAnimationFrame(()=>{
908          _tabsScrollRAF = 0;
909          updateTabsScrollState();
910        });
911      }, { passive:true });
912
913      W.addEventListener('resize', ()=>{ updateTabsScrollState(); }, { passive:true });
914    }
915
916    // Add a tiny "Copy" button to each KPI output card.
917    // This keeps the UI clean (button is only visible on hover/focus via CSS)
918    // and works for combined odds, stake splits, hedge amounts, etc.
919    function ensureResultCopyButtons(scope){
920      qsa('.iceto-ot-kpi', scope).forEach(kpi=>{
921        if (qs('.iceto-ot-copyResult', kpi)) return;
922        const btn = document.createElement('button');
923        btn.type = 'button';
924        btn.className = 'iceto-ot-copyResult';
925        btn.textContent = 'Copy';
926        btn.setAttribute('aria-label', 'Copy result');
927        kpi.appendChild(btn);
928      });
929    }
930
931// Locale-safe normalization: normalize on paste and on blur (not on each keystroke).
932// This keeps typing smooth, but prevents mixed formats like "1.000,50" from breaking calculations.
933panel.addEventListener('paste', function(e) {
934    const el = e.target;
935    if (!(el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement)) return;
936
937    const ot = (el.getAttribute('data-ot') || '').toLowerCase();
938    const clip = e.clipboardData && e.clipboardData.getData('text/plain');
939    if (!clip) return;
940
941    // Record an undo snapshot for paste actions. We often normalize pasted text
942    // (either immediately or in a setTimeout), which can break native undo.
943    pushUndo(el);
944
945    // For single-odds inputs, prevent "digit merge" from messy lines and keep Ctrl+Z undo.
946    // - Odds converter fields: dec / am / frac
947    // - Any-format single odds inputs (Kelly, Value, Lay, Free bet, etc.): data-ot="odds" (INPUT only)
948    const isConverter = (ot === 'dec' || ot === 'am' || ot === 'frac');
949    const isSingleAny = (ot === 'odds' && el.tagName.toUpperCase() === 'INPUT');
950    const isMarkedOdds = (el.getAttribute('data-ot-odds') === '1' && el.tagName.toUpperCase() === 'INPUT');
951
952    const isSingleOddsInput = (isSingleAny || isMarkedOdds);
953
954    if (isConverter || isSingleOddsInput){
955      const info = findFirstOddsToken(clip);
956      if (info && info.decimal > 1){
957        e.preventDefault();
958
959        const insertVal = isConverter
960          ? formatOddsForConverterField(info, ot)
961          : info.decimalStr;
962
963        applyPasteText(el, insertVal);
964
965        // Trigger calculations / sync on the same tick.
966        try{
967          el.dispatchEvent(new Event('input', {bubbles:true}));
968        } catch (err) {}
969
970        return;
971      }
972    }
973
974    // Default behaviour + post-normalize
975    if (!shouldNormalizeEl(el)) return;
976
977    setTimeout(function() {
978      normalizeElementValue(el);
979      try{ el.dispatchEvent(new Event('input', {bubbles: true})); } catch (err) {}
980    }, 0);
981  }, true);
982
983panel.addEventListener('blur', function(e){
984  const el = e.target;
985  if (!shouldNormalizeEl(el)) return;
986  normalizeElementValue(el);
987  try { el.dispatchEvent(new Event('input', { bubbles:true })); } catch(ex){}
988}, true);
989
990
991// Keyboard flow: Enter jumps to the next field (skips textareas).
992panel.addEventListener('keydown', function(e){
993  if (e.key !== 'Enter') return;
994  const el = e.target;
995  if (!(el instanceof HTMLInputElement || el instanceof HTMLSelectElement)) return;
996  // Do not hijack Enter inside multiline lists (textareas).
997  if (el.tagName && el.tagName.toUpperCase() === 'TEXTAREA') return;
998
999  // Only handle Enter for fields inside the tool body (not header buttons).
1000  if (bodyEl && !bodyEl.contains(el)) return;
1001
1002  e.preventDefault();
1003
1004  const scope = bodyEl || panel;
1005  const focusables = qsa('input, select', scope).filter(n=>{
1006    if (!(n instanceof HTMLElement)) return false;
1007    if (n instanceof HTMLInputElement){
1008      if (n.type === 'hidden') return false;
1009      if (n.disabled || n.readOnly) return false;
1010    } else {
1011      if ((n).disabled) return false;
1012    }
1013    // Skip elements not visible (display:none) or detached
1014    if (!n.offsetParent) return false;
1015    return true;
1016  });
1017
1018  const idx = focusables.indexOf(el);
1019  if (idx < 0) return;
1020
1021  const next = focusables[idx + 1];
1022  if (next){
1023    try{ next.focus({preventScroll:true}); } catch(ex){ next.focus(); }
1024    if (next instanceof HTMLInputElement){
1025      try{ next.select(); } catch(ex){}
1026    }
1027  } else {
1028    try{ el.blur(); } catch(ex){}
1029  }
1030}, true);
1031
1032    const order = String(CFG.tabOrder || '').split(',').map(s=>s.trim()).filter(Boolean);
1033    const tabs = (order.length ? order : TAB_DEFS.map(t=>t.id)).filter(id=>TAB_MAP[id]).map(id=>TAB_MAP[id]);
1034
1035    const KEY_LAST = 'icetoOddsTools:lastTab';
1036    const prefer = (CFG.defaultTab || '').trim();
1037    const last = W.localStorage ? (localStorage.getItem(KEY_LAST) || '') : '';
1038    const initial = (tabs.find(t=>t.id===prefer) ? prefer : (tabs.find(t=>t.id===last) ? last : tabs[0].id));
1039
1040    let active = null;
1041
1042    function setActive(id){
1043      const t = TAB_MAP[id] ? id : tabs[0].id;
1044      active = t;
1045      ACTIVE_TAB_ID = t;
1046      try { panel.dataset.activeTab = t; } catch(e){}
1047      qsa('.iceto-ot-tab', tabsEl).forEach(btn=>{
1048        btn.classList.toggle('is-active', btn.dataset.tab === t);
1049        btn.setAttribute('aria-selected', btn.dataset.tab === t ? 'true' : 'false');
1050      });
1051      bodyEl.innerHTML = '';
1052      TAB_MAP[t].render(bodyEl);
1053      ensureResultCopyButtons(bodyEl);
1054      // Keep the active tab visible on mobile and update fade edges.
1055      scrollActiveTabIntoView();
1056      updateTabsScrollState();
1057      if (W.localStorage) localStorage.setItem(KEY_LAST, t);
1058    }
1059
1060    tabs.forEach((t)=>{
1061      const b = document.createElement('button');
1062      b.type = 'button';
1063      b.className = 'iceto-ot-tab';
1064      b.dataset.tab = t.id;
1065      b.textContent = t.label;
1066      b.setAttribute('role','tab');
1067      b.addEventListener('click', ()=>setActive(t.id));
1068      tabsEl.appendChild(b);
1069    });
1070
1071    panel.addEventListener('keydown', (e)=>{
1072      const t = e.target;
1073      // Custom undo for paste-normalized fields.
1074      if ((e.ctrlKey || e.metaKey) && !e.shiftKey && (e.key === 'z' || e.key === 'Z')){
1075        if (t instanceof HTMLInputElement || t instanceof HTMLTextAreaElement){
1076          const prev = popUndo(t);
1077          if (prev !== null){
1078            e.preventDefault();
1079            t.value = prev;
1080            try{ t.dispatchEvent(new Event('input', {bubbles:true})); } catch(err){}
1081            return;
1082          }
1083        }
1084      }
1085      if (!embedded && e.key === 'Escape'){ e.preventDefault(); closePanel(); }
1086    });
1087
1088    // action buttons
1089    const actCopy = qs('[data-ot-action="copy"]', panel);
1090    const actReset = qs('[data-ot-action="reset"]', panel);
1091    const actPage = qs('[data-ot-action="page"]', panel);
1092    const actClose = qs('[data-ot-action="close"]', panel);
1093
1094    function resetActiveTab(){
1095      const scope = bodyEl || panel;
1096      // Clear only the active tool fields (inputs + textareas). Do not change tab selection.
1097      qsa('input, textarea', scope).forEach(el=>{
1098        if (el.disabled || el.readOnly) return;
1099        const type = (el.getAttribute('type') || '').toLowerCase();
1100        if (type === 'checkbox' || type === 'radio'){
1101          el.checked = false;
1102          try{ el.dispatchEvent(new Event('change', {bubbles:true})); } catch(e){}
1103          return;
1104        }
1105        el.value = '';
1106        try{ el.dispatchEvent(new Event('input', {bubbles:true})); } catch(e){}
1107        try{ el.dispatchEvent(new Event('change', {bubbles:true})); } catch(e){}
1108      });
1109
1110      // Hide inline warning boxes if present.
1111      const w = qs('[data-ot="warn"]', scope);
1112      if (w){ w.textContent = ''; w.style.display = 'none'; }
1113    }
1114
1115    if (actCopy) actCopy.addEventListener('click', ()=>{
1116      const line = buildCleanOddsLine();
1117      if (!line){ toast('Nothing to copy'); return; }
1118      copyText(line);
1119    });
1120    if (actReset) actReset.addEventListener('click', ()=>{
1121      resetActiveTab();
1122      toast('Cleared');
1123    });
1124    if (actPage) actPage.addEventListener('click', ()=>{
1125      try { W.location.href = (CFG.pageUrl || '/odds-calculator'); } catch(e){}
1126    });
1127    if (actClose) actClose.addEventListener('click', closePanel);
1128
1129    // Per-KPI copy buttons
1130    panel.addEventListener('click', (e)=>{
1131      const btn = e.target && e.target.closest ? e.target.closest('.iceto-ot-copyResult') : null;
1132      if (!btn) return;
1133      e.preventDefault();
1134      e.stopPropagation();
1135      const kpi = btn.closest('.iceto-ot-kpi');
1136      const valEl = kpi ? qs('.v', kpi) : null;
1137      const txt = valEl ? String(valEl.textContent || '').trim() : '';
1138      if (!txt || txt === '–'){
1139        toast('Nothing to copy');
1140        return;
1141      }
1142      copyText(txt);
1143    });
1144
1145    // mount
1146    if (embedded){
1147      container.innerHTML = '';
1148      container.appendChild(panel);
1149    } else {
1150      wrap.appendChild(panel);
1151      root.appendChild(wrap);
1152    }
1153
1154    setActive(initial);
1155
1156    // Focus trap (lightweight)
1157    if (!embedded){
1158      setTimeout(()=>{
1159        const f = panel.querySelector('input,textarea,select,button');
1160        if (f) f.focus({preventScroll:true});
1161      }, 0);
1162    }
1163  }
1164
1165  // Event bindings for FAB
1166  fab.addEventListener('click', openPanel);
1167  fab.addEventListener('keydown', (e)=>{
1168    if (e.key === 'Enter' || e.key === ' '){ e.preventDefault(); openPanel(); }
1169  });
1170
1171
1172  function tryMountDedicated(context){
1173    const ctx = (context && context.querySelector) ? context : document;
1174    const pl = ctx.querySelector('[data-iceto-ot-page="1"]');
1175    if (!pl) return;
1176    if (pl.getAttribute('data-iceto-ot-mounted') === '1') return;
1177    pl.setAttribute('data-iceto-ot-mounted', '1');
1178
1179    const fabNow = document.querySelector('[data-iceto-ot-fab="1"]');
1180    if (fabNow) fabNow.style.display = 'none';
1181
1182    applySchemeMarker();
1183    mountPanel({embedded:true, container: pl});
1184  }
1185
1186  // Mount embedded panel on initial load (direct visit).
1187  tryMountDedicated(document);
1188
1189  // Mount embedded panel when XenForo swaps in new content via AJAX navigation.
1190  document.addEventListener('xf:load', function(e){
1191    tryMountDedicated((e && e.target) ? e.target : document);
1192  });
1193
1194  // --- Copy line (smart per tool)
1195  function buildCleanOddsLine(){
1196    // NOTE: this is intentionally a "copy everything" builder, not only odds.
1197    // It upgrades the existing "Copy line" button to output a complete, tab-aware summary.
1198    const panel = qs('.iceto-ot-panel');
1199    if (!panel) return '';
1200
1201    const tabEl = qs('.iceto-ot-body', panel) || panel;
1202    const tabId = (ACTIVE_TAB_ID || (panel.dataset ? panel.dataset.activeTab : '') || '').toLowerCase();
1203
1204    const txt = (sel) => {
1205      const el = qs(sel, tabEl);
1206      return el ? String(el.textContent || '').trim() : '';
1207    };
1208    const val = (sel) => {
1209      const el = qs(sel, tabEl);
1210      return el ? String(el.value || '').trim() : '';
1211    };
1212
1213    const kpis = () => qsa('.iceto-ot-kpi', tabEl).map(k => {
1214      const kk = (qs('.k', k) ? qs('.k', k).textContent : '').trim();
1215      const vv = (qs('.v', k) ? qs('.v', k).textContent : '').trim();
1216      return { k: kk, v: vv };
1217    }).filter(p => p.v && p.v !== '–');
1218
1219    const joinPairs = (pairs) => pairs.map(p => (p.k ? (p.k + ' ' + p.v) : p.v)).join(' | ');
1220
1221    const fmtOddsList = (odds, sep) => (odds || []).map(o => fmtNum(o, 2)).join(sep || ' ');
1222
1223    // --- Parlay: 1.90 x 2.30 x 1.70 = 7.429 | stake 100 | return 742.90 | profit 642.90
1224    if (tabId === 'parlay'){
1225      const odds = parseOddsListFromText(val('[data-ot="list"]'), 20);
1226      if (odds.length < 2) return '';
1227      const comb = txt('[data-ot-k="odds"]');
1228      const be = txt('[data-ot-k="be"]');
1229      const imp = txt('[data-ot-k="imp"]');
1230      const stakeRaw = val('[data-ot="stake"]');
1231      const ret = txt('[data-ot-k="ret"]');
1232      const prof = txt('[data-ot-k="prof"]');
1233
1234      const parts = [];
1235      const main = fmtOddsList(odds, ' x ') + ((comb && comb !== '–') ? (' = ' + comb) : '');
1236      parts.push(main);
1237
1238      if (imp && imp !== '–') parts.push('implied ' + imp);
1239      if (be && be !== '–') parts.push('break-even ' + be);
1240
1241      const st = num(stakeRaw);
1242      const hasStake = Number.isFinite(st) && st > 0;
1243      if (hasStake){
1244        parts.push('stake ' + fmtMoney(st));
1245        if (ret && ret !== '–') parts.push('return ' + ret);
1246        if (prof && prof !== '–') parts.push('profit ' + prof);
1247      }
1248
1249      const label = TAB_MAP[tabId] ? TAB_MAP[tabId].label : '';
1250      const prefix = label ? (label + ': ') : '';
1251      return prefix + parts.join(' | ');
1252    }
1253
1254    // --- Dutch: list odds + stake split + guaranteed return
1255    if (tabId === 'dutch'){
1256      const odds = parseOddsListFromText(val('[data-ot="odds"]'), 20);
1257      if (!odds.length) return '';
1258      const stakeRaw = val('[data-ot="s"]');
1259      const st = num(stakeRaw);
1260      const hasStake = Number.isFinite(st) && st > 0;
1261
1262      const split = txt('[data-ot-k="split"]');
1263      const ret = txt('[data-ot-k="ret"]');
1264      const prof = txt('[data-ot-k="prof"]');
1265
1266      const parts = [];
1267
1268      if (!hasStake){
1269        // If there is no stake entered, do not include stake-dependent outputs (split/return/profit).
1270        parts.push(fmtOddsList(odds, ' / '));
1271      } else {
1272        // Pair odds with stakes when possible
1273        let paired = '';
1274        if (split && split !== '–'){
1275          const stakeParts = split.split('/').map(s => s.trim()).filter(Boolean);
1276          if (stakeParts.length === odds.length){
1277            paired = odds.map((o,i)=>fmtNum(o,2) + '=' + stakeParts[i]).join(' / ');
1278          }
1279        }
1280
1281        if (paired){
1282          parts.push(paired);
1283        } else {
1284          parts.push(fmtOddsList(odds, ' / '));
1285          if (split && split !== '–') parts.push('split ' + split);
1286        }
1287
1288        parts.push('stake ' + fmtMoney(st));
1289        if (ret && ret !== '–') parts.push('guaranteed return ' + ret);
1290        if (prof && prof !== '–') parts.push('profit ' + prof);
1291      }
1292
1293      const label = TAB_MAP[tabId] ? TAB_MAP[tabId].label : '';
1294      const prefix = label ? (label + ': ') : '';
1295      return prefix + parts.join(' | ');
1296    }
1297
1298    // --- Arb: include odds + stakes + profit %
1299    if (tabId === 'arb'){
1300      const mode = val('[data-ot="mode"]') || '';
1301      const a = val('[data-ot="a"]');
1302      const b = val('[data-ot="b"]');
1303      const c = val('[data-ot="c"]');
1304
1305      const stakeRaw = val('[data-ot="s"]');
1306      const st = num(stakeRaw);
1307      const hasStake = Number.isFinite(st) && st > 0;
1308
1309      const oddsPicked = parseOddsFromText([a,b,c].filter(Boolean).join(' '));
1310      const odds = (mode === '2way') ? oddsPicked.slice(0,2) : oddsPicked.slice(0,3);
1311
1312      if (!odds.length) return '';
1313
1314      const parts = [];
1315      parts.push('odds ' + fmtOddsList(odds, ' / '));
1316
1317      const arbPct = txt('[data-ot-k="arb"]');
1318      const profitPct = txt('[data-ot-k="profit"]');
1319      const totRet = txt('[data-ot-k="ret"]');
1320
1321      if (arbPct && arbPct !== '–') parts.push('arb ' + arbPct);
1322      if (profitPct && profitPct !== '–') parts.push('profit ' + profitPct);
1323      if (totRet && totRet !== '–') parts.push('total return ' + totRet);
1324
1325      if (hasStake){
1326        parts.push('stake ' + fmtMoney(st));
1327
1328        // Prefer the detailed per-outcome rows if available
1329        const rows = qsa('.iceto-ot-returnsRow', tabEl);
1330        if (rows && rows.length){
1331          const rowTxt = rows.map(r => {
1332            const cells = Array.from(r.children).map(x => (x.textContent || '').trim());
1333            // [outcome, stake, return, profit]
1334            if (cells.length >= 4 && cells[1] && cells[1] !== '–'){
1335              const out = [];
1336              out.push(cells[0]);
1337              out.push('stake ' + cells[1]);
1338              if (cells[2] && cells[2] !== '–') out.push('return ' + cells[2]);
1339              if (cells[3] && cells[3] !== '–') out.push('profit ' + cells[3]);
1340              return out.join(' ');
1341            }
1342            return '';
1343          }
1343).filter(Boolean).join(' / ');
1344          if (rowTxt) parts.push(rowTxt);
1345        } else {
1346          const stLine = txt('[data-ot-k="st"]');
1347          if (stLine && stLine !== '–') parts.push('stakes ' + stLine);
1348        }
1349      }
1350
1351      const label = TAB_MAP[tabId] ? TAB_MAP[tabId].label : '';
1352      const prefix = label ? (label + ': ') : '';
1353      return prefix + parts.join(' | ');
1354    }
1355
1356    // --- Odds / Margin / others: include inputs + KPI outputs
1357    // For these tools, "copy everything" is best represented as:
1358    // Inputs (only if non-empty) + outputs (KPI cards).
1359    const pairs = [];
1360
1361    // Try to capture the main inputs in a stable order per tool.
1362    const pushInput = (label, raw) => {
1363      const v = String(raw || '').trim();
1364      if (!v) return;
1365      pairs.push({ k: label + ':', v });
1366    };
1367
1368    if (tabId === 'odds'){
1369      pushInput('Decimal', val('[data-ot="dec"]'));
1370      pushInput('American', val('[data-ot="am"]'));
1371      pushInput('Fractional', val('[data-ot="frac"]'));
1372    } else if (tabId === 'margin'){
1373      pushInput('Side A', val('[data-ot="a"]'));
1374      pushInput('Side B', val('[data-ot="b"]'));
1375    } else if (tabId === 'hedge'){
1376      pushInput('Initial stake', val('[data-ot="s"]'));
1377      pushInput('Initial odds', val('[data-ot="o1"]'));
1378      pushInput('Hedge odds', val('[data-ot="o2"]'));
1379    } else if (tabId === 'kelly'){
1380      pushInput('Odds', val('[data-ot="odds"]'));
1381      pushInput('Win %', val('[data-ot="p"]'));
1382      pushInput('Bankroll', val('[data-ot="br"]'));
1383    } else if (tabId === 'value'){
1384      pushInput('Odds', val('[data-ot="odds"]'));
1385      pushInput('Prob %', val('[data-ot="p"]'));
1386      pushInput('Stake', val('[data-ot="s"]'));
1387    } else if (tabId === 'lay'){
1388      pushInput('Back odds', val('[data-ot="bo"]'));
1389      pushInput('Back stake', val('[data-ot="bs"]'));
1390      pushInput('Lay odds', val('[data-ot="lo"]'));
1391    } else if (tabId === 'freebet'){
1392      pushInput('Free bet stake', val('[data-ot="fb"]'));
1393      pushInput('Back odds', val('[data-ot="bo"]'));
1394      pushInput('Lay odds', val('[data-ot="lo"]'));
1395    }
1396
1397    const stakeGuess = (()=>{
1398      const sels = ['[data-ot="stake"]','[data-ot="s"]','[data-ot="st"]','[data-ot="bet"]','[data-ot="fb"]'];
1399      for (const sel of sels){
1400        const n = num(val(sel));
1401        if (Number.isFinite(n) && n > 0) return n;
1402      }
1403      return NaN;
1404    })();
1405    const hasStake = Number.isFinite(stakeGuess) && stakeGuess > 0;
1406
1407    const isStakeKey = (k)=>/stake/i.test(k || '');
1408    const isReturnKey = (k)=>/(return|payout|guaranteed return|equal return)/i.test(k || '');
1409    const isProfitKey = (k)=>/(profit|net profit)/i.test(k || '');
1410    const isStakeRelated = (k)=>/(stake|return|profit|payout|liability|split)/i.test(k || '');
1411
1412    let out = kpis();
1413
1414    // Ensure stake/return/profit appear only when stake exists, and in the same order everywhere.
1415    if (!hasStake){
1416      out = out.filter(p => !isStakeRelated(p.k));
1417    } else {
1418      const stakeParts = out.filter(p => isStakeKey(p.k));
1419      const returnParts = out.filter(p => isReturnKey(p.k));
1420      const profitParts = out.filter(p => isProfitKey(p.k));
1421      const rest = out.filter(p => !(isStakeKey(p.k) || isReturnKey(p.k) || isProfitKey(p.k)));
1422      out = rest.concat(stakeParts, returnParts, profitParts);
1423    }
1424
1425    const outTxt = joinPairs(out);
1426
1427    const inputTxt = pairs.length ? joinPairs(pairs) : '';
1428    const label = TAB_MAP[tabId] ? TAB_MAP[tabId].label : '';
1429    const prefix = label ? (label + ': ') : '';
1430
1431    const full = [inputTxt, outTxt].filter(Boolean).join(' | ');
1432    return full ? (prefix + full) : '';
1433  }
1434
1435  // --- Renderers
1436
1437  function renderOdds(rootEl){
1438    rootEl.innerHTML = `
1439      <div class="iceto-ot-row">
1440        <div class="iceto-ot-field">
1441          <label>Decimal</label>
1442          <input type="text" inputmode="decimal" enterkeyhint="next" placeholder="1.91" data-ot="dec">
1443        </div>
1444        <div class="iceto-ot-field">
1445          <label>American</label>
1446          <input type="text" inputmode="numeric" enterkeyhint="next" placeholder="-110" data-ot="am">
1447        </div>
1448        <div class="iceto-ot-field">
1449          <label>Fractional</label>
1450          <input type="text" inputmode="numeric" enterkeyhint="next" placeholder="10/11" data-ot="frac">
1451        </div>
1452      </div>
1453      <div class="iceto-ot-warn" data-ot="warn" style="display:none;"></div>
1454      <div class="iceto-ot-kpis">
1455        <div class="iceto-ot-kpi"><div class="k">Implied probability</div><div class="v" data-ot-k="imp">–</div></div>
1456        <div class="iceto-ot-kpi"><div class="k">Fair odds (no margin)</div><div class="v" data-ot-k="fair">–</div></div>
1457      </div>
1458      <div class="iceto-ot-note">Tip: Paste any odds text into any box. The tool will auto-detect and fill the best match.</div>
1459    `;
1460
1461    const decI = qs('[data-ot="dec"]', rootEl);
1462    const amI  = qs('[data-ot="am"]', rootEl);
1463    const frI  = qs('[data-ot="frac"]', rootEl);
1464
1465    function safeSet(el, v, force){
1466      if (force || document.activeElement !== el){
1467        el.value = v;
1468      }
1469    }
1470
1471    function sync(from, opts){
1472      opts = opts || {};
1473      const forceFormat = !!opts.forceFormat;
1474      let dec = NaN;
1475
1476      if (from === 'dec'){
1477        dec = num(decI.value);
1478        if (Number.isFinite(dec) && dec < 1.01) dec = NaN;
1479      }
1480      if (from === 'am'){
1481        const a = num(amI.value);
1482        if (Number.isFinite(a) && Math.abs(a) >= 100){
1483          dec = americanToDec(a);
1484        }
1485      }
1486      if (from === 'frac'){
1487        // Only accept a fully-formed fraction while typing
1488        const v = String(frI.value || '').trim();
1489        if (/^\d+\s*\/\s*\d+$/.test(v)){
1490          dec = fracToDec(v);
1491        }
1492      }
1493
1494      // Paste detection (auto)
1495      if (!Number.isFinite(dec)){
1496        const pick = parseOddsFromText(from === 'dec' ? decI.value : from==='am'? amI.value : frI.value)[0];
1497        if (Number.isFinite(pick)) dec = pick;
1498      }
1499
1500      if (Number.isFinite(dec)){
1501        setInlineWarn(rootEl, '');
1502        // Do not reformat the field the user is actively typing in.
1503        // Reformat on blur (or when forced).
1504        safeSet(decI, fmtNum(dec, 2), forceFormat);
1505        const am = decToAmerican(dec);
1506        safeSet(amI, Number.isFinite(am) ? (am>0 ? '+'+am : String(am)) : '', forceFormat);
1507        safeSet(frI, decToFrac(dec), forceFormat);
1508
1509        const imp = decToImp(dec);
1510        qs('[data-ot-k="imp"]', rootEl).textContent = fmtPct(imp);
1511        qs('[data-ot-k="fair"]', rootEl).textContent = fmtNum(1/imp, 2);
1512      } else {
1513        qs('[data-ot-k="imp"]', rootEl).textContent = '–';
1514        qs('[data-ot-k="fair"]', rootEl).textContent = '–';
1515        const any = String(decI.value||'').trim() || String(amI.value||'').trim() || String(frI.value||'').trim();
1516        setInlineWarn(rootEl, any ? 'Invalid odds. Use decimal > 1.00, American +/-100+, or fractional 10/11.' : '');
1517      }
1518    }
1519
1520    decI.addEventListener('input', ()=>sync('dec'));
1521    amI.addEventListener('input', ()=>sync('am'));
1522    frI.addEventListener('input', ()=>sync('frac'));
1523
1524    // Format only after the user finishes editing.
1525    decI.addEventListener('blur', ()=>sync('dec', {forceFormat:true}));
1526    amI.addEventListener('blur', ()=>sync('am', {forceFormat:true}));
1527    frI.addEventListener('blur', ()=>sync('frac', {forceFormat:true}));
1528  }
1529
1530  function renderProb(rootEl){
1531    rootEl.innerHTML = `
1532      <div class="iceto-ot-row">
1533        <div class="iceto-ot-field">
1534          <label>Odds (any format)</label>
1535          <input type="text" inputmode="decimal" enterkeyhint="next" placeholder="1.91" data-ot="dec">
1536        </div>
1537        <div class="iceto-ot-field">
1538          <label>Implied probability (%)</label>
1539          <input type="text" inputmode="decimal" placeholder="52.36" data-ot="pct">
1540        </div>
1541      </div>
1542      <div class="iceto-ot-kpis">
1543        <div class="iceto-ot-kpi"><div class="k">Implied probability</div><div class="v" data-ot-k="imp">–</div></div>
1544        <div class="iceto-ot-kpi"><div class="k">Fair odds</div><div class="v" data-ot-k="fair">–</div></div>
1545      </div>
1546      <div class="iceto-ot-note">If you paste text with odds, we will detect the first valid odds and calculate implied probability.</div>
1547    `;
1548    const decI = qs('[data-ot="dec"]', rootEl);
1549    const pctI = qs('[data-ot="pct"]', rootEl);
1550
1551    function fromDec(){
1552      let dec = num(decI.value);
1553      if (!Number.isFinite(dec)){
1554        const pick = parseOddsFromText(decI.value)[0];
1555        if (Number.isFinite(pick)) dec = pick;
1556      }
1557      const imp = decToImp(dec);
1558      if (Number.isFinite(imp)){
1559        pctI.value = (imp*100).toFixed(2);
1560        qs('[data-ot-k="imp"]', rootEl).textContent = fmtPct(imp);
1561        qs('[data-ot-k="fair"]', rootEl).textContent = fmtNum(1/imp,2);
1562      }
1563    }
1564    function fromPct(){
1565      const p = num(pctI.value) / 100;
1566      if (Number.isFinite(p) && p>0 && p<1){
1567        const dec = 1/p;
1568        decI.value = dec.toFixed(2);
1569        qs('[data-ot-k="imp"]', rootEl).textContent = fmtPct(p);
1570        qs('[data-ot-k="fair"]', rootEl).textContent = fmtNum(dec,2);
1571      }
1572    }
1573    decI.addEventListener('input', fromDec);
1574    pctI.addEventListener('input', fromPct);
1575  }
1576
1577  function renderMargin(rootEl){
1578    rootEl.innerHTML = `
1579      <div class="iceto-ot-row">
1580        <div class="iceto-ot-field">
1581          <label>Side A odds (any format)</label>
1582          <input type="text" inputmode="decimal" placeholder="1.91" data-ot="a">
1583        </div>
1584        <div class="iceto-ot-field">
1585          <label>Side B odds (any format)</label>
1586          <input type="text" inputmode="decimal" placeholder="1.91" data-ot="b">
1587        </div>
1588      </div>
1589      <div class="iceto-ot-warn" data-ot="warn" style="display:none;"></div>
1590      <div class="iceto-ot-kpis">
1591        <div class="iceto-ot-kpi"><div class="k">Overround (margin)</div><div class="v" data-ot-k="m">–</div></div>
1592        <div class="iceto-ot-kpi"><div class="k">Fair A / Fair B</div><div class="v" data-ot-k="fair">–</div></div>
1593      </div>
1594      <div class="iceto-ot-note">Tip: click Copy line after pasting messy odds text into either field.</div>
1595    `;
1596    const aI = qs('[data-ot="a"]', rootEl);
1597    const bI = qs('[data-ot="b"]', rootEl);
1598
1599    function calc(){
1600      setInlineWarn(rootEl, '');
1601      let a = oddsToDec(aI.value), b = oddsToDec(bI.value);
1602
1603      if (!Number.isFinite(a) || !Number.isFinite(b)){
1604        const picks = parseOddsFromText((aI.value||'')+' '+(bI.value||''));
1605        if (picks.length >= 2){
1606          a = picks[0]; b = picks[1];
1607          aI.value = a.toFixed(2);
1608          bI.value = b.toFixed(2);
1609        }
1610      }
1611
1612      const ia = decToImp(a), ib = decToImp(b);
1613      if (Number.isFinite(ia) && Number.isFinite(ib)){
1614        const over = ia + ib;
1615        const margin = over - 1;
1616        qs('[data-ot-k="m"]', rootEl).textContent = (margin*100).toFixed(2) + '%';
1617        const fa = ia / over;
1618        const fb = ib / over;
1619        const fairA = 1/fa, fairB = 1/fb;
1620        qs('[data-ot-k="fair"]', rootEl).textContent = fmtNum(fairA,2) + ' / ' + fmtNum(fairB,2);
1621      } else {
1622        qs('[data-ot-k="m"]', rootEl).textContent = '–';
1623        qs('[data-ot-k="fair"]', rootEl).textContent = '–';
1624        const any = String(aI.value||'').trim() || String(bI.value||'').trim();
1625        if (any) setInlineWarn(rootEl, 'Enter two valid odds (both greater than 1.00).');
1626      }
1627    }
1628
1629    aI.addEventListener('input', calc);
1630    bI.addEventListener('input', calc);
1631  }
1632
1633    function renderArb(rootEl){
1634    rootEl.innerHTML = `
1635<div class="iceto-ot-row">
1636  <div class="iceto-ot-field" style="flex:1 1 180px; min-width: 180px;">
1637    <label>Mode</label>
1638    <select data-ot="mode">
1639      <option value="1x2">1X2 (football)</option>
1640      <option value="2way">2-way</option>
1641    </select>
1642  </div>
1643  <div class="iceto-ot-field" style="flex:1 1 140px;">
1644    <label>Commission % (optional)</label>
1645    <input type="text" inputmode="decimal" placeholder="0" data-ot="comm">
1646  </div>
1647  <div class="iceto-ot-field" style="flex:1 1 160px;">
1648    <label>Round stakes</label>
1649    <select data-ot="round">
1650      <option value="0">No rounding</option>
1651      <option value="0.01">0.01</option>
1652      <option value="0.1">0.1</option>
1653      <option value="0.5">0.5</option>
1654      <option value="1">1</option>
1655      <option value="5">5</option>
1656    </select>
1657  </div>
1658</div>
1659
1660<div class="iceto-ot-row">
1661  <div class="iceto-ot-field">
1662    <label data-ot-ol="a">Odds 1 (Home)</label>
1663    <input type="text" inputmode="decimal" placeholder="2.00" data-ot="a">
1664  </div>
1665  <div class="iceto-ot-field">
1666    <label data-ot-ol="b">Odds X (Draw)</label>
1667    <input type="text" inputmode="decimal" placeholder="2.00" data-ot="b">
1668  </div>
1669  <div class="iceto-ot-field" data-ot-wrap="c">
1670    <label data-ot-ol="c">Odds 2 (Away)</label>
1671    <input type="text" inputmode="decimal" placeholder="2.00" data-ot="c">
1672  </div>
1673  <div class="iceto-ot-field">
1674    <label>Total stake</label>
1675    <input type="text" inputmode="decimal" enterkeyhint="next" placeholder="100" data-ot="s">
1676  </div>
1677</div>
1678
1679<div class="iceto-ot-row">
1680  <div class="iceto-ot-field">
1681    <label>Max total stake (optional)</label>
1682    <input type="text" inputmode="decimal" placeholder="-" data-ot="maxTotal">
1683  </div>
1684  <div class="iceto-ot-field">
1685    <label data-ot-maxl="a">Max stake 1 (optional)</label>
1686    <input type="text" inputmode="decimal" placeholder="-" data-ot="maxA">
1687  </div>
1688  <div class="iceto-ot-field">
1689    <label data-ot-maxl="b">Max stake X (optional)</label>
1690    <input type="text" inputmode="decimal" placeholder="-" data-ot="maxB">
1691  </div>
1692  <div class="iceto-ot-field" data-ot-wrap="maxC">
1693    <label data-ot-maxl="c">Max stake 2 (optional)</label>
1694    <input type="text" inputmode="decimal" placeholder="-" data-ot="maxC">
1695  </div>
1696</div>
1697
1698<div class="iceto-ot-kpis">
1699  <div class="iceto-ot-kpi"><div class="k">Arb %</div><div class="v" data-ot-k="arb">–</div></div>
1700  <div class="iceto-ot-kpi"><div class="k">Profit %</div><div class="v" data-ot-k="profit">–</div></div>
1701  <div class="iceto-ot-kpi"><div class="k">Total return</div><div class="v" data-ot-k="ret">–</div></div>
1702  <div class="iceto-ot-kpi"><div class="k" data-ot="stLabel">Stakes A / B</div><div class="v" data-ot-k="st">–</div></div>
1703</div>
1704
1705<div class="iceto-ot-warn" data-ot="warn" style="display:none;"></div>
1706
1707<div class="iceto-ot-returnsWrap" data-ot="returnsWrap" style="display:none;">
1708  <div class="iceto-ot-subhead">Per outcome (after commission + rounding)</div>
1709  <div class="iceto-ot-returns">
1710    <div class="iceto-ot-returnsHead">
1711      <div>Outcome</div><div>Stake</div><div>Return</div><div>Profit</div>
1712    </div>
1713    <div class="iceto-ot-returnsRows" data-ot="retRows"></div>
1714  </div>
1715</div>
1716
1717<div class="iceto-ot-note">
1718  Tip: paste any odds line into any box - we will extract up to 3 odds and fill the fields.
1719  Commission is treated like exchange commission (applied to winnings only). Rounding shows worst-case profit based on rounded stakes.
1720</div>
1721`;
1722
1723    const modeSel = qs('[data-ot="mode"]', rootEl);
1724    const commI = qs('[data-ot="comm"]', rootEl);
1725    const perCommI = qs('[data-ot="perComm"]', rootEl);
1726    const perCommRow = qs('[data-ot="perCommRow"]', rootEl);
1727    const commAI = qs('[data-ot="commA"]', rootEl);
1728    const commBI = qs('[data-ot="commB"]', rootEl);
1729    const commCWrap = qs('[data-ot-wrap="commC"]', rootEl);
1730    const commCI = qs('[data-ot="commC"]', rootEl);
1731    const roundSel = qs('[data-ot="round"]', rootEl);
1732
1733    const aI = qs('[data-ot="a"]', rootEl);
1734    const bI = qs('[data-ot="b"]', rootEl);
1735    const cWrap = qs('[data-ot-wrap="c"]', rootEl);
1736    const cI = qs('[data-ot="c"]', rootEl);
1737    const sI = qs('[data-ot="s"]', rootEl);
1738
1739    const maxTotalI = qs('[data-ot="maxTotal"]', rootEl);
1740
1741    const maxAI = qs('[data-ot="maxA"]', rootEl);
1742    const maxBI = qs('[data-ot="maxB"]', rootEl);
1743    const maxCWrap = qs('[data-ot-wrap="maxC"]', rootEl);
1744    const maxCI = qs('[data-ot="maxC"]', rootEl);
1745
1746    const warnEl = qs('[data-ot="warn"]', rootEl);
1747    const retWrap = qs('[data-ot="returnsWrap"]', rootEl);
1748    const retRows = qs('[data-ot="retRows"]', rootEl);
1749    const stLabel = qs('[data-ot="stLabel"]', rootEl);
1750
1751    const olA = qs('[data-ot-ol="a"]', rootEl);
1752    const olB = qs('[data-ot-ol="b"]', rootEl);
1753    const olC = qs('[data-ot-ol="c"]', rootEl);
1754
1755    const maxLA = qs('[data-ot-maxl="a"]', rootEl);
1756    const maxLB = qs('[data-ot-maxl="b"]', rootEl);
1757    const maxLC = qs('[data-ot-maxl="c"]', rootEl);
1758
1759    const commLA = qs('[data-ot-comml="a"]', rootEl);
1760    const commLB = qs('[data-ot-comml="b"]', rootEl);
1761    const commLC = qs('[data-ot-comml="c"]', rootEl);
1762
1763    const KEY_MODE = 'icetoOddsTools:arbMode';
1764    const KEY_COMM = 'icetoOddsTools:arbComm';
1765    const KEY_PERCOMM = 'icetoOddsTools:arbPerComm';
1766    const KEY_COMMA = 'icetoOddsTools:arbCommA';
1767    const KEY_COMMB = 'icetoOddsTools:arbCommB';
1768    const KEY_COMMC = 'icetoOddsTools:arbCommC';
1769    const KEY_ROUND = 'icetoOddsTools:arbRound';
1770    const KEY_MAXT = 'icetoOddsTools:arbMaxTotal';
1771    const KEY_MAXA = 'icetoOddsTools:arbMaxA';
1772    const KEY_MAXB = 'icetoOddsTools:arbMaxB';
1773    const KEY_MAXC = 'icetoOddsTools:arbMaxC';
1774
1775    function setK(key, val){
1776      const el = qs(`[data-ot-k="${key}"]`, rootEl);
1777      if (el) el.textContent = val;
1778    }
1779    function setWarn(msg){
1780      if (!warnEl) return;
1781      if (msg){
1782        warnEl.textContent = msg;
1783        warnEl.style.display = '';
1784      } else {
1785        warnEl.textContent = '';
1786        warnEl.style.display = 'none';
1787      }
1788    }
1789    function setReturns(rows){
1790      if (!retWrap || !retRows) return;
1791      if (!rows || !rows.length){
1792        retRows.innerHTML = '';
1793        retWrap.style.display = 'none';
1794        return;
1795      }
1796      retWrap.style.display = '';
1797      retRows.innerHTML = rows.map(r => `
1798        <div class="iceto-ot-returnsRow">
1799          <div class="o">${r.outcome}</div>
1800          <div>${r.stake}</div>
1801          <div>${r.ret}</div>
1802          <div class="${r.profitClass}">${r.prof}</div>
1803        </div>
1804      `).join('');
1805    }
1806
1807    // Floor rounding is safer when book limits exist (never exceeds max limits).
1808    function floorTo(x, step){
1809      if (!Number.isFinite(x) || !Number.isFinite(step) || step <= 0) return x;
1810      // avoid floating edge cases
1811      return Math.floor((x + 1e-12) / step) * step;
1812    }
1813
1814    function updateMode(){
1815      const mode = String(modeSel.value || '1x2');
1816
1817      const perOn = !!(perCommI && perCommI.checked);
1818      if (perCommRow) perCommRow.style.display = perOn ? '' : 'none';
1819
1820      if (mode === '2way'){
1821        if (cWrap) cWrap.style.display = 'none';
1822        if (maxCWrap) maxCWrap.style.display = 'none';
1823        if (commCWrap) commCWrap.style.display = 'none';
1824
1825        if (olA) olA.textContent = 'Odds A';
1826        if (olB) olB.textContent = 'Odds B';
1827        if (olC) olC.textContent = 'Odds C';
1828
1829        if (maxLA) maxLA.textContent = 'Max stake A (optional)';
1830        if (maxLB) maxLB.textContent = 'Max stake B (optional)';
1831        if (maxLC) maxLC.textContent = 'Max stake C (optional)';
1832
1833        if (commLA) commLA.textContent = 'Comm A % (optional)';
1834        if (commLB) commLB.textContent = 'Comm B % (optional)';
1835        if (commLC) commLC.textContent = 'Comm C % (optional)';
1836
1837        if (stLabel) stLabel.textContent = 'Stakes A / B';
1838      } else {
1839        if (cWrap) cWrap.style.display = '';
1840        if (maxCWrap) maxCWrap.style.display = '';
1841        if (commCWrap) commCWrap.style.display = '';
1842
1843        if (olA) olA.textContent = 'Odds 1 (Home)';
1844        if (olB) olB.textContent = 'Odds X (Draw)';
1845        if (olC) olC.textContent = 'Odds 2 (Away)';
1846
1847        if (maxLA) maxLA.textContent = 'Max stake 1 (optional)';
1848        if (maxLB) maxLB.textContent = 'Max stake X (optional)';
1849        if (maxLC) maxLC.textContent = 'Max stake 2 (optional)';
1850
1851        if (commLA) commLA.textContent = 'Comm 1 % (optional)';
1852        if (commLB) commLB.textContent = 'Comm X % (optional)';
1853        if (commLC) commLC.textContent = 'Comm 2 % (optional)';
1854
1855        if (stLabel) stLabel.textContent = 'Stakes 1 / X / 2';
1856      }
1857    }
1858
1859    // load preferences
1860    try {
1861      if (W.localStorage){
1862        const lm = localStorage.getItem(KEY_MODE);
1863        const lc = localStorage.getItem(KEY_COMM);
1864        const lr = localStorage.getItem(KEY_ROUND);
1865        const lmt = localStorage.getItem(KEY_MAXT);
1866        const lma = localStorage.getItem(KEY_MAXA);
1867        const lmb = localStorage.getItem(KEY_MAXB);
1868        const lmc = localStorage.getItem(KEY_MAXC);
1869        const lpc = localStorage.getItem(KEY_PERCOMM);
1870        const lca = localStorage.getItem(KEY_COMMA);
1871        const lcb = localStorage.getItem(KEY_COMMB);
1872        const lcc = localStorage.getItem(KEY_COMMC);
1873        if (lm) modeSel.value = lm;
1874        if (lc !== null) commI.value = lc;
1875        if (lr !== null) roundSel.value = lr;
1876        if (lmt !== null) maxTotalI.value = lmt;
1877        if (lma !== null) maxAI.value = lma;
1878        if (lmb !== null) maxBI.value = lmb;
1879        if (lmc !== null && maxCI) maxCI.value = lmc;
1880        if (lpc !== null && perCommI) perCommI.checked = (lpc === '1');
1881        if (lca !== null && commAI) commAI.value = lca;
1882        if (lcb !== null && commBI) commBI.value = lcb;
1883        if (lcc !== null && commCI) commCI.value = lcc;
1884      }
1885    } catch(e){}
1886
1887    updateMode();
1888
1889    modeSel.addEventListener('change', ()=>{
1890      updateMode();
1891      // per-outcome commission row may change visibility
1892      if (perCommRow) perCommRow.style.display = (perCommI && perCommI.checked) ? '' : 'none';
1893      calc();
1894    });
1895
1896    
1897
1898	    
1899    // Smarter 1X2 extraction: detect labeled odds like "1 1.91 X 3.40 2 4.50" or "Home @ 1.91 Draw 3.40 Away 4.50"
1900    function pickOddsAfterLabel(re, fullText){
1901      const m = re.exec(fullText);
1902      if (!m) return NaN;
1903      const start = m.index + m[0].length;
1904      const after = fullText.slice(start, start + 80);
1905      const picks = parseOddsFromText(after);
1906      return (picks && picks.length) ? picks[0] : NaN;
1907    }
1908
1909    function extractArbOddsFromPastedText(pastedText, mode){
1910      const clean = String(pastedText || '').replace(/[\u2212\u2013\u2014]/g, '-');
1911      const all = parseOddsFromText(clean);
1912      if (!all.length) return null;
1913
1914      if (String(mode) === '2way'){
1915        return { a: all[0], b: all[1], c: NaN };
1916      }
1917
1918      const padded = ' ' + clean + ' ';
1919      const o1 = pickOddsAfterLabel(/(?:^|[\s\(\[\{])(?:1|H|HOME)\s*(?:odds\s*)?(?:[:@\-]\s*)?/i, padded);
1920      const ox = pickOddsAfterLabel(/(?:^|[\s\(\[\{])(?:X|D|DRAW)\s*(?:odds\s*)?(?:[:@\-]\s*)?/i, padded);
1921      const o2 = pickOddsAfterLabel(/(?:^|[\s\(\[\{])(?:2|A|AWAY)\s*(?:odds\s*)?(?:[:@\-]\s*)?/i, padded);
1922
1923      let aV = o1, bV = ox, cV = o2;
1924
1925      const gotCount = [aV,bV,cV].filter(v=>Number.isFinite(v)).length;
1926
1927      if (gotCount >= 2){
1928        // Fill any missing side(s) from remaining odds in the pasted text (preserving order).
1929        function isSame(x,y){ return Math.abs(x-y) < 1e-9; }
1930        const used = [aV,bV,cV].filter(v=>Number.isFinite(v));
1931        const remaining = all.filter(v => !used.some(u => isSame(u, v)));
1932
1933        if (!Number.isFinite(aV) && remaining.length) aV = remaining.shift();
1934        if (!Number.isFinite(bV) && remaining.length) bV = remaining.shift();
1935        if (!Number.isFinite(cV) && remaining.length) cV = remaining.shift();
1936
1937        return { a: aV, b: bV, c: cV };
1938      }
1939
1940      // Fallback: first three odds in order.
1941      return { a: all[0], b: all[1], c: all[2] };
1942    }
1943
1944// Fill Arb odds from a pasted odds line (only on paste) - avoids interfering while typing.
1945	    function handleArbOddsPaste(e){
1946      const el = e.target;
1947      window.setTimeout(()=>{
1948        const txt = String(el.value || '');
1949        const mode = String(modeSel.value || '1x2');
1950        const parsed = extractArbOddsFromPastedText(txt, mode);
1951        if (!parsed) return;
1952
1953        if (Number.isFinite(parsed.a)) aI.value = fmtNum(parsed.a, 2);
1954        if (Number.isFinite(parsed.b)) bI.value = fmtNum(parsed.b, 2);
1955        if (mode !== '2way' && Number.isFinite(parsed.c)) cI.value = fmtNum(parsed.c, 2);
1956
1957        calc();
1958      }, 0);
1959    }
1960	    [aI, bI, cI].forEach(inp => inp.addEventListener('paste', handleArbOddsPaste));
1961
1962    function roundTo(x, step){
1963      step = Number(step) || 0;
1964      if (!step) return x;
1965      return Math.round(x / step) * step;
1966    }
1967
1968    function computeMaxStake(mode, invs, sumInv, maxTotal, maxEach, roundStep){
1969      let maxPossible = Infinity;
1970      let limiter = '';
1971
1972      if (isFinite(maxTotal) && maxTotal > 0){
1973        maxPossible = Math.min(maxPossible, maxTotal);
1974        limiter = limiter || 'Max total stake';
1975      }
1976
1977      // per-book limits
1978      for (let i=0; i<invs.length; i++){
1979        const lim = maxEach[i];
1980        if (isFinite(lim) && lim > 0){
1981          const capT = lim * (sumInv / invs[i]);
1982          if (capT < maxPossible){
1983            maxPossible = capT;
1984            limiter = (mode === '2way')
1985              ? ('Max stake ' + (i===0 ? 'A' : 'B'))
1986              : ('Max stake ' + (i===0 ? '1' : (i===1 ? 'X' : '2')));
1987          }
1988        }
1989      }
1990
1991      if (!isFinite(maxPossible) || maxPossible <= 0){
1992        return {max: 0, limiter: ''};
1993      }
1994
1995      // align total to rounding step (if any)
1996      if (roundStep > 0){
1997        // when limits exist, keep it safe (floor). otherwise round to nearest.
1998        const hasAnyLimit = isFinite(maxTotal) || maxEach.some(v => isFinite(v));
1999        maxPossible = hasAnyLimit ? floorTo(maxPossible, roundStep) : roundTo(maxPossible, roundStep);
2000      }
2001
2002      return {max: maxPossible, limiter};
2003    }
2004function calc(){
2005      setWarn('');
2006      setReturns(null);
2007
2008      const mode = String(modeSel.value || '1x2');
2009
2010      // Odds fields accept decimal / American (+/-) / fractional.
2011const need = (mode === '2way') ? 2 : 3;
2012const aStr = (aI.value || '').trim();
2013const bStr = (bI.value || '').trim();
2014const cStr = (cI.value || '').trim();
2015let a = oddsToDec(aStr), b = oddsToDec(bStr), c = oddsToDec(cStr), sDesired = num(sI.value);
2016
2017      // Commission as percent (exchange style: applied to winnings only).
2018      let commPct = num(commI.value);
2019      if (!Number.isFinite(commPct)) commPct = 0;
2020      commPct = clamp(commPct, 0, 30);
2021      const comm = commPct / 100;
2022
2023      // Validate required odds for this mode (no silent "partial" calc).
2024const oddsStrs = [aStr, bStr, cStr];
2025const oddsVals = [a, b, c];
2026let filled = 0;
2027let invalid = false;
2028
2029for (let i = 0; i < need; i++)
2030{
2031  if (oddsStrs[i]) { filled++; }
2032  if (oddsStrs[i] && (!Number.isFinite(oddsVals[i]) || oddsVals[i] <= 1.0001))
2033  {
2034    invalid = true;
2035  }
2036}
2037
2038if (invalid)
2039{
2040  setWarn('Invalid odds - must be greater than 1.00.');
2041  setK('arb','–'); setK('profit','–'); setK('ret','–'); setK('st','–');
2042  return;
2043}
2044
2045if (filled === 0)
2046{
2047  setK('arb','–'); setK('profit','–'); setK('ret','–'); setK('st','–');
2048  return;
2049}
2050
2051if (filled < need)
2052{
2053  setWarn('Enter ' + need + ' odds for this mode.');
2054  setK('arb','–'); setK('profit','–'); setK('ret','–'); setK('st','–');
2055  return;
2056}
2057
2058const rawOdds = oddsVals.slice(0, need);
2059
2060      // Effective payout factor with exchange commission:
2061      // net return per unit stake = 1 + (odds-1)*(1-comm)
2062      const factors = rawOdds.map(o => 1 + (o - 1) * (1 - comm));
2063      const invs = factors.map(f => 1 / f);
2064      const sumInv = invs.reduce((aa,bb)=>aa+bb, 0);
2065
2066      if (!Number.isFinite(sumInv) || sumInv <= 0){
2067        setK('arb','–'); setK('profit','–'); setK('ret','–'); setK('st','–');
2068        return;
2069      }
2070
2071      const arb = 1 - sumInv;               // positive when arb exists
2072      const totalReturn = 1 / sumInv;       // exact (no rounding) return factor
2073      const profitPct = totalReturn - 1;    // exact (no rounding) profit factor
2074
2075      setK('arb', (arb*100).toFixed(2) + '%');
2076      setK('profit', (profitPct*100).toFixed(2) + '%');
2077      setK('ret', (totalReturn*100).toFixed(2) + '%');
2078
2079      const lbls = (mode === '1x2' && rawOdds.length === 3) ? ['1','X','2'] : (rawOdds.length === 2 ? ['A','B'] : ['A','B','C']);
2080
2081      // If no stake, still show exact stake ratios.
2082      if (!Number.isFinite(sDesired) || sDesired <= 0){
2083        const parts = invs.map((inv,i)=>{
2084          const pct = (inv/sumInv)*100;
2085          return `${lbls[i]} ${pct.toFixed(2)}%`;
2086        });
2087        setK('st', parts.join(' / '));
2088        return;
2089      }
2090
2091      // Constraints
2092      let maxTotal = num(maxTotalI.value);
2093      if (!Number.isFinite(maxTotal) || maxTotal <= 0) maxTotal = Infinity;
2094
2095      let maxA = num(maxAI.value);
2096      let maxB = num(maxBI.value);
2097      let maxC = num(maxCI ? maxCI.value : '');
2098      if (!Number.isFinite(maxA) || maxA <= 0) maxA = Infinity;
2099      if (!Number.isFinite(maxB) || maxB <= 0) maxB = Infinity;
2100      if (!Number.isFinite(maxC) || maxC <= 0) maxC = Infinity;
2101      const maxEachArr = [maxA, maxB];
2102      if (mode !== '2way' && rawOdds.length === 3) maxEachArr.push(maxC);
2103
2104      
2105      const hasLimits = (maxTotal !== Infinity) || maxEachArr.some(l => l !== Infinity);
2106
2107      let sUsed = sDesired;
2108
2109      if (maxTotal !== Infinity){
2110        sUsed = Math.min(sUsed, maxTotal);
2111      }
2112      // cap total by per-outcome (book) limits based on exact ratios
2113      let capBooks = Infinity;
2114      for (let i=0; i<maxEachArr.length; i++){
2115        const lim = maxEachArr[i];
2116        if (lim === Infinity) continue;
2117        const inv = invs[i];
2118        if (!Number.isFinite(inv) || inv <= 0) continue;
2119        const capT = lim * (sumInv / inv);
2120        capBooks = Math.min(capBooks, capT);
2121      }
2122      if (capBooks !== Infinity){
2123        sUsed = Math.min(sUsed, capBooks);
2124      }
2125
2126      if (!Number.isFinite(sUsed) || sUsed <= 0){
2127        setK('st','–');
2128        setWarn('Stake limits make this staking impossible.');
2129        return;
2130      }
2131
2132      const stakesExact = invs.map(inv => sUsed * (inv / sumInv));
2133      const roundStep = num(roundSel.value);
2134
2135      let stakes = stakesExact.slice();
2136      let totalUsed = sUsed;
2137      let worstProfitPct = profitPct;
2138
2139      if (Number.isFinite(roundStep) && roundStep > 0){
2140        const step = roundStep;
2141
2142        // Keep total stake aligned to the rounding step (and safe under limits).
2143        const targetTotal = hasLimits ? floorTo(sUsed, step) : roundTo(sUsed, step);
2144
2145        // First pass: round each stake
2146        stakes = stakesExact.map(x => (hasLimits ? floorTo(x, step) : roundTo(x, step)));
2147        totalUsed = stakes.reduce((aa,bb)=>aa + (Number.isFinite(bb) ? bb : 0), 0);
2148
2149        if (totalUsed <= 0){
2150          setK('st','–');
2151          return;
2152        }
2153
2154        // Second pass: distribute the leftover in step-sized chunks to hit target total
2155        // while keeping within max book limits and maximizing worst-case profit.
2156        const limitsEach = maxEachArr.slice(0, stakes.length);
2157
2158        const profitsFor = (stArr, tot) => stArr.map((st, i) => st * factors[i] - tot);
2159
2160        const normDiffSteps = Math.round((targetTotal - totalUsed) / step);
2161        let stepsToAdjust = Math.abs(normDiffSteps);
2162        const dir = normDiffSteps >= 0 ? 1 : -1;
2163
2164        // safety: avoid runaway loops with weird inputs
2165        stepsToAdjust = Math.min(stepsToAdjust, 1000);
2166
2167        for (let k = 0; k < stepsToAdjust; k++){
2168          let bestI = -1;
2169          let bestWorst = -Infinity;
2170
2171          for (let i = 0; i < stakes.length; i++){
2172            const next = stakes[i] + dir * step;
2173            if (next < 0) continue;
2174            if (dir > 0 && isFinite(limitsEach[i]) && next > limitsEach[i] + 1e-9) continue;
2175
2176            const tmp = stakes.slice();
2177            tmp[i] = next;
2178            const tmpTotal = totalUsed + dir * step;
2179
2180            const profits = profitsFor(tmp, tmpTotal);
2181            const worst = Math.min.apply(null, profits);
2182
2183            if (worst > bestWorst){
2184              bestWorst = worst;
2185              bestI = i;
2186            }
2187          }
2188
2189          if (bestI === -1){
2190            break;
2191          }
2192
2193          stakes[bestI] += dir * step;
2194          totalUsed += dir * step;
2195        }
2196
2197        // update worstProfitPct after rounding distribution (final worst-case recalculated below too)
2198        const profitsNow = profitsFor(stakes, totalUsed);
2199        const worstAbs = Math.min.apply(null, profitsNow);
2200        worstProfitPct = (worstAbs / totalUsed) * 100;
2201      }
2202
2203      // Limit check post-rounding
2204      let over = false;
2205      for (let i=0; i<stakes.length; i++){
2206        const lim = maxEachArr[i] || Infinity;
2207        if (lim !== Infinity && stakes[i] > lim + 1e-9) over = true;
2208      }
2209      if (over){
2210        setWarn('Rounded stakes exceed one of your book limits. Try smaller rounding or lower total stake.');
2211      }
2212
2213      const parts = stakes.map((x,i)=>`${lbls[i]} ${fmtMoney(x)}`);
2214      const usedNote = (Math.abs(totalUsed - sDesired) > 1e-6) ? ` (used: ${fmtMoney(totalUsed)})` : (Number.isFinite(roundStep) && roundStep > 0 ? ` (total: ${fmtMoney(totalUsed)})` : '');
2215      setK('st', parts.join(' / ') + usedNote);
2216
2217      // Per outcome returns table
2218      const rows = stakes.map((si,i)=>{
2219        const ret = si * factors[i];
2220        const prof = ret - totalUsed;
2221        return {
2222          outcome: lbls[i],
2223          stake: fmtMoney(si),
2224          ret: fmtMoney(ret),
2225          prof: fmtMoney(prof),
2226          profitClass: (prof >= 0 ? 'pos' : 'neg')
2227        };
2228      });
2229      setReturns(rows);
2230
2231      // Tiny arb warning (use worst-case when rounding)
2232      const tiny = worstProfitPct * 100;
2233      if (tiny > 0 && tiny < 0.20){
2234        setWarn(`Warning: profit is only ${tiny.toFixed(2)}% after rounding/fees - limits and rounding often wipe this out.`);
2235      }
2236
2237      // persist preferences
2238      try {
2239        if (W.localStorage){
2240          localStorage.setItem(KEY_MODE, mode);
2241          localStorage.setItem(KEY_COMM, String(commPct));
2242          localStorage.setItem(KEY_PERCOMM, (perCommI && perCommI.checked) ? '1' : '0');
2243          localStorage.setItem(KEY_COMMA, String(commAI ? (commAI.value || '') : ''));
2244          localStorage.setItem(KEY_COMMB, String(commBI ? (commBI.value || '') : ''));
2245          localStorage.setItem(KEY_COMMC, String(commCI ? (commCI.value || '') : ''));
2246          localStorage.setItem(KEY_ROUND, String(roundSel.value || '0'));
2247          localStorage.setItem(KEY_MAXT, String(maxTotalI.value || ''));
2248          localStorage.setItem(KEY_MAXA, String(maxAI.value || ''));
2249          localStorage.setItem(KEY_MAXB, String(maxBI.value || ''));
2250          localStorage.setItem(KEY_MAXC, String(maxCI ? (maxCI.value || '') : ''));
2251        }
2252      } catch(e){}
2253    }
2254
2255    [aI,bI,cI,sI,commI,perCommI,commAI,commBI,commCI,roundSel,maxTotalI,maxAI,maxBI,maxCI].filter(Boolean).forEach(el=>{
2256      el.addEventListener('input', calc);
2257      el.addEventListener('change', calc);
2258    });
2259
2260
2261
2262setTimeout(calc, 0);
2263  }
2264
2265  
2266
2267  function renderParlay(rootEl){
2268    rootEl.innerHTML = `
2269      <div class="iceto-ot-field">
2270        <label>Odds list (2-20)</label>
2271        <textarea rows="3" placeholder="1.91, -110, 10/11, 2.05" data-ot="list"></textarea>
2272        <div class="iceto-ot-chips" data-ot="chips" style="display:none;"></div>
2273        <div class="iceto-ot-note">Paste like: 1.91, 2, 2, 2 - or mixed: 1.91, -110, 10/11, 2.05 - or use x/×: 1.91 x 2.05 x 1.80. Decimal commas like 1,91 are supported.</div>
2274      </div>
2275
2276      <div class="iceto-ot-row">
2277        <div class="iceto-ot-field">
2278          <label>Stake (optional)</label>
2279          <input type="text" inputmode="decimal" placeholder="100" data-ot="stake">
2280        </div>
2281        <div class="iceto-ot-field">
2282          <label>Odds decimals</label>
2283          <select data-ot="dpOdds">
2284            <option value="2">2 dp</option>
2285            <option value="3">3 dp</option>
2286            <option value="4" selected>4 dp</option>
2287          </select>
2288        </div>
2289        <div class="iceto-ot-field">
2290          <label>Money decimals</label>
2291          <select data-ot="dpMoney">
2292            <option value="2" selected>2 dp</option>
2293            <option value="3">3 dp</option>
2294            <option value="4">4 dp</option>
2295          </select>
2296        </div>
2297      </div>
2298
2299      <div class="iceto-ot-warn" data-ot="warn" style="display:none;"></div>
2300      <div class="iceto-ot-kpis">
2301        <div class="iceto-ot-kpi"><div class="k">Combined odds</div><div class="v" data-ot-k="odds">–</div></div>
2302        <div class="iceto-ot-kpi"><div class="k">Break-even %</div><div class="v" data-ot-k="be">–</div></div>
2303        <div class="iceto-ot-kpi"><div class="k">Implied probability</div><div class="v" data-ot-k="imp">–</div></div>
2304        <div class="iceto-ot-kpi"><div class="k">Total return</div><div class="v" data-ot-k="ret">–</div></div>
2305        <div class="iceto-ot-kpi"><div class="k">Profit</div><div class="v" data-ot-k="prof">–</div></div>
2306      </div>
2307    `;
2308
2309    const listTa = qs('[data-ot="list"]', rootEl);
2310    const stakeI = qs('[data-ot="stake"]', rootEl);
2311    const chipsEl = qs('[data-ot="chips"]', rootEl);
2312    const dpOddsSel = qs('[data-ot="dpOdds"]', rootEl);
2313    const dpMoneySel = qs('[data-ot="dpMoney"]', rootEl);
2314
2315    function setK(key, val){
2316      const el = qs(`[data-ot-k="${key}"]`, rootEl);
2317      if (el) el.textContent = val;
2318    }
2319
2320    function fmtMoneyDp(n, dp){
2321      if (!Number.isFinite(n)) return '–';
2322      return n.toFixed(dp);
2323    }
2324
2325    function renderChips(odds){
2326      if (!chipsEl) return;
2327      if (!odds || !odds.length){
2328        chipsEl.innerHTML = '';
2329        chipsEl.style.display = 'none';
2330        return;
2331      }
2332      chipsEl.style.display = '';
2333      chipsEl.innerHTML = odds.map((o,i)=>`
2334        <button type="button" class="iceto-ot-chip" data-idx="${i}">${fmtNum(o,2)} <span class="x">×</span></button>
2335      `).join('');
2336    }
2337
2338    function calc(){
2339      setInlineWarn(rootEl, '');
2340      if (hasBadOddsToken(listTa.value || '')){
2341        setK('odds','–'); setK('be','–'); setK('imp','–'); setK('ret','–'); setK('prof','–');
2342        setInlineWarn(rootEl, 'Invalid odds in list - odds must be greater than 1.00.');
2343        return;
2344      }
2345
2346      const odds = parseOddsListFromText(listTa.value || '', 20);
2347      renderChips(odds);
2348
2349      if (odds.length < 2){
2350        setK('odds','–'); setK('be','–'); setK('imp','–'); setK('ret','–'); setK('prof','–');
2351        return;
2352      }
2353
2354      let combined = 1;
2355      for (const o of odds) combined *= o;
2356
2357      const dpOdds = clamp(parseInt((dpOddsSel && dpOddsSel.value) || '4', 10) || 4, 2, 4);
2358      const dpMoney = clamp(parseInt((dpMoneySel && dpMoneySel.value) || '2', 10) || 2, 2, 4);
2359
2360      const implied = 1 / combined; // as probability
2361      const bePct = implied * 100;
2362
2363      setK('odds', fmtNum(combined, dpOdds));
2364      setK('be', bePct.toFixed(2) + '%');
2365      setK('imp', bePct.toFixed(2) + '% (1 in ' + fmtNum(combined, dpOdds) + ')');
2366
2367      const stake = num(stakeI.value);
2368      if (isBadStake(stake)){
2369        setK('ret','–');
2370        setK('prof','–');
2371        setInlineWarn(rootEl, 'Stake cannot be negative.');
2372        return;
2373      }
2374      if (Number.isFinite(stake) && stake > 0){
2375        const ret = stake * combined;
2376        const prof = ret - stake;
2377        setK('ret', fmtMoneyDp(ret, dpMoney));
2378        setK('prof', fmtMoneyDp(prof, dpMoney));
2379      } else {
2380        setK('ret','–');
2381        setK('prof','–');
2382      }
2383    }
2384
2385    if (chipsEl) chipsEl.addEventListener('click', (e)=>{
2386      const btn = e.target.closest('.iceto-ot-chip');
2387      if (!btn) return;
2388      const idx = parseInt(btn.getAttribute('data-idx')||'',10);
2389      if (!Number.isFinite(idx)) return;
2390      const odds = parseOddsListFromText(listTa.value || '', 20);
2391      if (idx < 0 || idx >= odds.length) return;
2392      odds.splice(idx, 1);
2393      listTa.value = odds.map(o => fmtNum(o,2)).join(', ');
2394      calc();
2395    });
2396
2397    listTa.addEventListener('input', calc);
2398    listTa.addEventListener('change', calc);
2399    stakeI.addEventListener('input', calc);
2400    stakeI.addEventListener('change', calc);
2401    if (dpOddsSel) dpOddsSel.addEventListener('change', calc);
2402    if (dpMoneySel) dpMoneySel.addEventListener('change', calc);
2403
2404    setTimeout(calc, 0);
2405  }
2406
2407
2408  function renderKelly(rootEl){
2409    rootEl.innerHTML = `
2410      <div class="iceto-ot-row">
2411        <div class="iceto-ot-field">
2412          <label>Odds (any format)</label>
2413          <input type="text" inputmode="decimal" enterkeyhint="next" placeholder="2.00, -110, 10/11" data-ot="odds">
2414        </div>
2415        <div class="iceto-ot-field">
2416          <label>Your win probability (%)</label>
2417          <input type="text" inputmode="decimal" enterkeyhint="next" placeholder="55" data-ot="p">
2418        </div>
2419        <div class="iceto-ot-field">
2420          <label>Bankroll</label>
2421          <input type="text" inputmode="decimal" placeholder="1000" data-ot="br">
2422        </div>
2423      </div>
2424      <div class="iceto-ot-warn" data-ot="warn" style="display:none;"></div>
2425      <div class="iceto-ot-kpis">
2426        <div class="iceto-ot-kpi"><div class="k">Kelly fraction</div><div class="v" data-ot-k="k">–</div></div>
2427        <div class="iceto-ot-kpi"><div class="k">Stake (full Kelly)</div><div class="v" data-ot-k="s">–</div></div>
2428      </div>
2429      <div class="iceto-ot-note">Kelly = (b*p - q) / b where b = odds-1, p = win prob, q = 1-p. Use fractions of Kelly in practice.</div>
2430    `;
2431    const oI=qs('[data-ot="odds"]', rootEl), pI=qs('[data-ot="p"]', rootEl), brI=qs('[data-ot="br"]', rootEl);
2432    function calc(){
2433      setInlineWarn(rootEl, '');
2434      const odds = oddsToDec(oI.value);
2435      const pRaw = num(pI.value);
2436      const p = pRaw/100;
2437      const br = num(brI.value);
2438
2439      // reset outputs to avoid stale values
2440      qs('[data-ot-k="k"]', rootEl).textContent = '–';
2441      qs('[data-ot-k="s"]', rootEl).textContent = '–';
2442
2443      if (isBadStake(br)){
2444        setInlineWarn(rootEl, 'Bankroll cannot be negative.');
2445        return;
2446      }
2447      if (String(oI.value||'').trim() && Number.isFinite(odds) && odds <= 1){
2448        setInlineWarn(rootEl, 'Odds must be greater than 1.00.');
2449        return;
2450      }
2451      if (Number.isFinite(pRaw) && (pRaw < 0 || pRaw > 100)){
2452        setInlineWarn(rootEl, 'Probability must be between 0 and 100.');
2453        return;
2454      }
2455
2456      const b = odds - 1;
2457      const q = 1 - p;
2458      if (Number.isFinite(b) && b>0 && Number.isFinite(p) && p>0 && p<1){
2459        const k = (b*p - q) / b;
2460        const kClamped = clamp(k, 0, 1);
2461        qs('[data-ot-k="k"]', rootEl).textContent = (kClamped*100).toFixed(2) + '%';
2462        if (Number.isFinite(br) && br>0){
2463          qs('[data-ot-k="s"]', rootEl).textContent = fmtMoney(br*kClamped);
2464        }
2465      }
2466    }
2467    [oI,pI,brI].forEach(el=>{
2468      el.addEventListener('input', calc);
2469      el.addEventListener('change', calc);
2470      el.addEventListener('paste', () => setTimeout(calc, 0));
2471    });
2472    setTimeout(calc, 0);
2473  }
2474
2475  function renderValue(rootEl){
2476    rootEl.innerHTML = `
2477      <div class="iceto-ot-row">
2478        <div class="iceto-ot-field">
2479          <label>Odds (any format)</label>
2480          <input type="text" inputmode="decimal" enterkeyhint="next" placeholder="2.00, -110, 10/11" data-ot="odds">
2481        </div>
2482        <div class="iceto-ot-field">
2483          <label>Your probability (%)</label>
2484          <input type="text" inputmode="decimal" enterkeyhint="next" placeholder="55" data-ot="p">
2485        </div>
2486        <div class="iceto-ot-field">
2487          <label>Stake</label>
2488          <input type="text" inputmode="decimal" enterkeyhint="next" placeholder="100" data-ot="s">
2489        </div>
2490      </div>
2491      <div class="iceto-ot-warn" data-ot="warn" style="display:none;"></div>
2492      <div class="iceto-ot-kpis">
2493        <div class="iceto-ot-kpi"><div class="k">Edge vs implied</div><div class="v" data-ot-k="e">–</div></div>
2494        <div class="iceto-ot-kpi"><div class="k">Expected value (EV)</div><div class="v" data-ot-k="ev">–</div></div>
2495      <div class="iceto-ot-kpi"><div class="k">Return if win</div><div class="v" data-ot-k="ret">–</div></div>
2496        <div class="iceto-ot-kpi"><div class="k">Profit if win</div><div class="v" data-ot-k="prof">–</div></div>
2497      </div>
2498      <div class="iceto-ot-note">Edge = your probability - implied probability. EV uses stake*(p*(odds-1) - (1-p)).</div>
2499    `;
2500    const oI=qs('[data-ot="odds"]', rootEl), pI=qs('[data-ot="p"]', rootEl), sI=qs('[data-ot="s"]', rootEl);
2501    const eEl = qs('[data-ot-k="e"]', rootEl);
2502    const evEl = qs('[data-ot-k="ev"]', rootEl);
2503    const retEl = qs('[data-ot-k="ret"]', rootEl);
2504    const profEl = qs('[data-ot-k="prof"]', rootEl);
2505    function calc(){
2506      setInlineWarn(rootEl, '');
2507      // reset outputs so stale values don't linger
2508      if (eEl) eEl.textContent = '–';
2509      if (evEl) evEl.textContent = '–';
2510        retEl.textContent = '–';
2511        profEl.textContent = '–';
2512
2513      // Odds can be decimal / American (+/-) / fractional.
2514      let odds = oddsToDec(oI.value);
2515      if (!Number.isFinite(odds)){
2516        const picks = parseOddsFromText(oI.value);
2517        if (picks && picks.length && Number.isFinite(picks[0].value)) odds = picks[0].value;
2518      }
2519      const pRaw = num(pI.value);
2520      const p = pRaw/100;
2521      const stake = num(sI.value);
2522
2523      if (isBadStake(stake)){
2524        setInlineWarn(rootEl, 'Stake cannot be negative.');
2525        return;
2526      }
2527      if (String(oI.value||'').trim() && Number.isFinite(odds) && odds <= 1){
2528        setInlineWarn(rootEl, 'Odds must be greater than 1.00.');
2529        return;
2530      }
2531      if (Number.isFinite(pRaw) && (pRaw < 0 || pRaw > 100)){
2532        setInlineWarn(rootEl, 'Probability must be between 0 and 100.');
2533        return;
2534      }
2535
2536      const imp = decToImp(odds);
2537      if (Number.isFinite(imp) && Number.isFinite(p) && p>0 && p<1){
2538        if (eEl) eEl.textContent = ((p-imp)*100).toFixed(2) + '%';
2539        if (Number.isFinite(stake) && stake>0){
2540          const ev = stake * (p*(odds-1) - (1-p));
2541          if (evEl) evEl.textContent = fmtMoney(ev);
2542        if (Number.isFinite(stake) && stake > 0 && Number.isFinite(odds) && odds > 1) {
2543          retEl.textContent = fmtMoney(stake * odds);
2544          profEl.textContent = fmtMoney(stake * (odds - 1));
2545        } else {
2546          retEl.textContent = '–';
2547          profEl.textContent = '–';
2548        }
2549        }
2550      }
2551    }
2552
2553    // Some browsers auto-fill without firing input - listen to change too.
2554    [oI,pI,sI].forEach(el=>{
2555      el.addEventListener('input', calc);
2556      el.addEventListener('change', calc);
2557    });
2558
2559    // Initialize outputs (handles prefilled values, history restore, etc.)
2560    setTimeout(calc, 0);
2561  }
2562
2563  function renderHedge(rootEl){
2564    rootEl.innerHTML = `
2565      <div class="iceto-ot-row">
2566        <div class="iceto-ot-field">
2567          <label>Initial stake</label>
2568          <input type="text" inputmode="decimal" enterkeyhint="next" placeholder="100" data-ot="s">
2569        </div>
2570        <div class="iceto-ot-field">
2571          <label>Initial odds (any format)</label>
2572          <input type="text" inputmode="decimal" enterkeyhint="next" placeholder="2.50" data-ot="o1" data-ot-odds="1">
2573        </div>
2574      </div>
2575      <div class="iceto-ot-row" style="margin-top:10px;">
2576        <div class="iceto-ot-field">
2577          <label>Hedge odds (any format)</label>
2578          <input type="text" inputmode="decimal" enterkeyhint="next" placeholder="1.70" data-ot="o2" data-ot-odds="1">
2579        </div>
2580        <div class="iceto-ot-field">
2581          <label>Commission % (optional)</label>
2582          <input type="text" inputmode="decimal" enterkeyhint="done" placeholder="0" data-ot="c">
2583        </div>
2584      </div>
2585      <div class="iceto-ot-warn" data-ot="warn" style="display:none;"></div>
2586      <div class="iceto-ot-kpis">
2587        <div class="iceto-ot-kpi"><div class="k">Hedge stake</div><div class="v" data-ot-k="hs">–</div></div>
2588        <div class="iceto-ot-kpi"><div class="k">Equal profit</div><div class="v" data-ot-k="p">–</div></div>
2589      </div>
2590      <div class="iceto-ot-note iceto-ot-roundNote" data-ot="round" style="display:none;"></div>
2591      <div class="iceto-ot-note">Simple hedge: choose stake so profits are equal regardless of outcome. Commission reduces hedge winnings (optional). Good for cashout comparisons.<
2591/div>
2592    `;
2593    const sI=qs('[data-ot="s"]', rootEl), o1I=qs('[data-ot="o1"]', rootEl), o2I=qs('[data-ot="o2"]', rootEl), cI=qs('[data-ot="c"]', rootEl);
2594    function calc(){
2595      setInlineWarn(rootEl, '');
2596      const S = num(sI.value), O1 = oddsToDec(o1I.value), O2 = oddsToDec(o2I.value);
2597      const cRaw = (cI ? String(cI.value || '').trim() : '');
2598      const cPct = num(cRaw);
2599      if (cRaw && !Number.isFinite(cPct)){
2600        qs('[data-ot-k="hs"]', rootEl).textContent = '–';
2601        qs('[data-ot-k="p"]', rootEl).textContent = '–';
2602        const roundEl = qs('[data-ot="round"]', rootEl);
2603        if (roundEl){ roundEl.style.display = 'none'; roundEl.textContent = ''; }
2604        setInlineWarn(rootEl, 'Commission must be a number.');
2605        return;
2606      }
2607      const comm = Number.isFinite(cPct) ? (cPct / 100) : 0;
2608
2609      if (isBadStake(S)){
2610        qs('[data-ot-k="hs"]', rootEl).textContent = '–';
2611        qs('[data-ot-k="p"]', rootEl).textContent = '–';
2612        const roundEl = qs('[data-ot="round"]', rootEl);
2613        if (roundEl){ roundEl.style.display = 'none'; roundEl.textContent = ''; }
2614        setInlineWarn(rootEl, 'Initial stake cannot be negative.');
2615        return;
2616      }
2617
2618      if ((String(o1I.value||'').trim() && Number.isFinite(O1) && O1 <= 1) || (String(o2I.value||'').trim() && Number.isFinite(O2) && O2 <= 1)){
2619        qs('[data-ot-k="hs"]', rootEl).textContent = '–';
2620        qs('[data-ot-k="p"]', rootEl).textContent = '–';
2621        const roundEl = qs('[data-ot="round"]', rootEl);
2622        if (roundEl){ roundEl.style.display = 'none'; roundEl.textContent = ''; }
2623        setInlineWarn(rootEl, 'Odds must be greater than 1.00.');
2624        return;
2625      }
2626
2627      if (Number.isFinite(S)&&S>0&&Number.isFinite(O1)&&O1>1&&Number.isFinite(O2)&&O2>1){
2628        // Equalize: profit if original wins = S*(O1-1) - H
2629        // profit if hedge wins = H*(O2-1) - S
2630        // set equal => S*(O1-1) - H = H*(O2-1) - S
2631        // => S*O1 = H*O2
2632        const netO2 = 1 + (O2 - 1) * (1 - comm);
2633        if (Number.isFinite(comm) && comm < 0){
2634          qs('[data-ot-k="hs"]', rootEl).textContent = '–';
2635          qs('[data-ot-k="p"]', rootEl).textContent = '–';
2636          const roundEl = qs('[data-ot="round"]', rootEl);
2637          if (roundEl){ roundEl.style.display = 'none'; roundEl.textContent = ''; }
2638          setInlineWarn(rootEl, 'Commission cannot be negative.');
2639          return;
2640        }
2641        if (!Number.isFinite(netO2) || netO2 <= 1){
2642          qs('[data-ot-k="hs"]', rootEl).textContent = '–';
2643          qs('[data-ot-k="p"]', rootEl).textContent = '–';
2644          const roundEl = qs('[data-ot="round"]', rootEl);
2645          if (roundEl){ roundEl.style.display = 'none'; roundEl.textContent = ''; }
2646          setInlineWarn(rootEl, 'Commission makes hedge odds invali
2646d.');
2647          return;
2648        }
2649        const H = (S*O1)/netO2;
2650        const P = S*(O1-1) - H;
2651        qs('[data-ot-k="hs"]', rootEl).textContent = fmtMoney(H);
2652        qs('[data-ot-k="p"]', rootEl).textContent = fmtMoney(P);
2653
2654        // Rounding note: if you place a rounded hedge stake, profits won't be perfectly equal.
2655        const roundEl = qs('[data-ot="round"]', rootEl);
2656        if (roundEl){
2657          const Hr = Math.round(H * 100) / 100;
2658          const p1 = S*(O1-1) - Hr;
2659          const p2 = Hr*(O2-1)*(1-comm) - S;
2660          const diff = p1 - p2;
2661          if (Math.abs(diff) >= 0.01){
2662            roundEl.style.display = '';
2663            roundEl.textContent = 'Rounded hedge stake ' + fmtMoney(Hr) + ' - profits ' + fmtMoney(p1) + ' / ' + fmtMoney(p2) + ' (diff ' + fmtMoney(diff) + ').';
2664          } else {
2665            roundEl.style.display = 'none';
2666            roundEl.textContent = '';
2667          }
2668        }
2669      } else {
2670        qs('[data-ot-k="hs"]', rootEl).textContent = '–';
2671        qs('[data-ot-k="p"]', rootEl).textContent = '–';
2672        const roundEl = qs('[data-ot="round"]', rootEl);
2673        if (roundEl){ roundEl.style.display = 'none'; roundEl.textContent = ''; }
2674      }
2675    }
2676    [sI,o1I,o2I,cI].forEach(el=>{
2677      el.addEventListener('input', calc);
2678      el.addEventListener('change', calc);
2679      el.addEventListener('paste', () => setTimeout(calc, 0));
2680    });
2681    setTimeout(calc, 0);
2682  }
2683
2684  function renderDutch(rootEl){
2685    rootEl.innerHTML = `
2686      <div class="iceto-ot-row iceto-ot-row--split">
2687        <div class="iceto-ot-field">
2688          <label>Odds list (any format) - dutch stake split for equal return</label>
2689          <textarea placeholder="2.20, -110, 10/11" data-ot="odds"></textarea>
2690          <div class="iceto-ot-chips" data-ot="chips" style="display:none;"></div>
2691          <div class="iceto-ot-note">Paste a mix of decimal, American (+/-), and fractional. Decimal commas like 1,91 are supported too.</div>
2692        </div>
2693        <div class="iceto-ot-field">
2694          <label>Total stake</label>
2695          <input type="text" inputmode="decimal" enterkeyhint="next" placeholder="100" data-ot="s">
2696        </div>
2697      </div>
2698      <div class="iceto-ot-warn" data-ot="warn" style="display:none;"></div>
2699      <div class="iceto-ot-kpis">
2700        <div class="iceto-ot-kpi"><div class="k">Stake split</div><div class="v" data-ot-k="split">–</div></div>
2701        <div class="iceto-ot-kpi"><div class="k">Total return</div><div class="v" data-ot-k="ret">–</div></div>
2702        <div class="iceto-ot-kpi"><div class="k">Profit</div><div class="v" data-ot-k="prof">–</div></div>
2703      </div>
2704      <div class="iceto-ot-note iceto-ot-roundNote" data-ot="round" style="display:none;"></div>
2705      <div class="iceto-ot-note">Dutching splits stakes across outcomes so the payout is the same whichever wins.</div>
2706    `;
2707    const ta=qs('[data-ot="odds"]', rootEl), sI=qs('[data-ot="s"]', rootEl);
2708    const chipsEl = qs('[data-ot="chips"]', rootEl);
2709
2710    function renderChips(odds){
2711      if (!chipsEl) return;
2712      if (!odds || !odds.length){
2713        chipsEl.innerHTML = '';
2714        chipsEl.style.display = 'none';
2715        return;
2716      }
2717      chipsEl.style.display = '';
2718      chipsEl.innerHTML = odds.map((o,i)=>
2719        `<button type="button" class="iceto-ot-chip" data-idx="${i}">${fmtNum(o,2)} <span class="x">×</span></button>`
2720      ).join('');
2721    }
2722
2723    function calc(){
2724      setInlineWarn(rootEl, '');
2725      const roundEl0 = qs('[data-ot="round"]', rootEl);
2726      if (roundEl0){ roundEl0.style.display = 'none'; roundEl0.textContent = ''; }
2727      if (hasBadOddsToken(ta.value || '')){
2728        qs('[data-ot-k="split"]', rootEl).textContent='–';
2729        qs('[data-ot-k="ret"]', rootEl).textContent='–';
2730        qs('[data-ot-k="prof"]', rootEl).textContent='–';
2731        setInlineWarn(rootEl, 'Invalid odds in list - odds must be greater than 1.00.');
2732        return;
2733      }
2734
2735      const decs = parseOddsListFromText(ta.value, 20);
2736      renderChips(decs);
2737      const S = num(sI.value);
2738      if (isBadStake(S)){
2739        qs('[data-ot-k="split"]', rootEl).textContent='–';
2740        qs('[data-ot-k="ret"]', rootEl).textContent='–';
2741        qs('[data-ot-k="prof"]', rootEl).textContent='–';
2742        setInlineWarn(rootEl, 'Total stake cannot be negative.');
2743        return;
2744      }
2745      if (!decs.length || !Number.isFinite(S) || S<=0){
2746        qs('[data-ot-k="split"]', rootEl).textContent='–';
2747        qs('[data-ot-k="ret"]', rootEl).textContent='–';
2748        qs('[data-ot-k="prof"]', rootEl).textContent='–';
2749        return;
2750      }
2751      const inv = decs.map(d=>1/d);
2752      const sum = inv.reduce((a,b)=>a+b,0);
2753      const ret = S / sum;
2754      const stakes = inv.map(i=>ret*i);
2755      qs('[data-ot-k="split"]', rootEl).textContent = stakes.map(x=>fmtMoney(x)).join(' / ');
2756      qs('[data-ot-k="ret"]', rootEl).textContent = fmtMoney(ret);
2757      qs('[data-ot-k="prof"]', rootEl).textContent = fmtMoney(ret - S);
2758
2759      // Rounding note: if you round each stake to cents, the total can differ slightly.
2760      const roundEl = qs('[data-ot="round"]', rootEl);
2761      if (roundEl){
2762        const stakesR = stakes.map(x => Math.round(x * 100) / 100);
2763        const sumR = stakesR.reduce((a,b)=>a+b,0);
2764        const diff = sumR - S;
2765        if (Math.abs(diff) >= 0.01){
2766          roundEl.style.display = '';
2767          roundEl.textContent = 'Rounded stakes total ' + fmtMoney(sumR) + ' (diff ' + fmtMoney(diff) + ').';
2768        } else {
2769          roundEl.style.display = 'none';
2770          roundEl.textContent = '';
2771        }
2772      }
2773    }
2774
2775    if (chipsEl) chipsEl.addEventListener('click', (e)=>{
2776      const btn = e.target.closest('.iceto-ot-chip');
2777      if (!btn) return;
2778      const idx = parseInt(btn.getAttribute('data-idx')||'',10);
2779      if (!Number.isFinite(idx)) return;
2780      const odds = parseOddsListFromText(ta.value || '', 20);
2781      if (idx < 0 || idx >= odds.length) return;
2782      odds.splice(idx, 1);
2783      ta.value = odds.map(o => fmtNum(o,2)).join(', ');
2784      calc();
2785    });
2786
2787    ta.addEventListener('input', calc);
2788    ta.addEventListener('change', calc);
2789    sI.addEventListener('input', calc);
2790    sI.addEventListener('change', calc);
2791
2792    setTimeout(calc, 0);
2793  }
2794
2795  function renderLay(rootEl){
2796    rootEl.innerHTML = `
2797      <div class="iceto-ot-row">
2798        <div class="iceto-ot-field">
2799          <label>Back odds (any format)</label>
2800          <input type="text" inputmode="decimal" placeholder="2.50" data-ot="bo" data-ot-odds="1">
2801        </div>
2802        <div class="iceto-ot-field">
2803          <label>Back stake</label>
2804          <input type="text" inputmode="decimal" placeholder="100" data-ot="bs">
2805        </div>
2806        <div class="iceto-ot-field">
2807          <label>Lay odds (any format)</label>
2808          <input type="text" inputmode="decimal" placeholder="2.20" data-ot="lo" data-ot-odds="1">
2809        </div>
2810        <div class="iceto-ot-field">
2811          <label>Commission % (optional)</label>
2812          <input type="text" inputmode="decimal" placeholder="0" data-ot="c">
2813        </div>
2814      </div>
2815      <div class="iceto-ot-warn" data-ot="warn" style="display:none;"></div>
2816      <div class="iceto-ot-kpis">
2817        <div class="iceto-ot-kpi"><div class="k">Lay stake (equal profit)</div><div class="v" data-ot-k="ls">–</div></div>
2818        <div class="iceto-ot-kpi"><div class="k">Liability</div><div class="v" data-ot-k="liab">–</div></div>
2819      <div class="iceto-ot-kpi"><div class="k">Net profit</div><div class="v" data-ot-k="prof">–</div></div>
2820      </div>
2821      <div class="iceto-ot-note">Exchange commission % is applied to lay winnings. Equal profit uses layStake = backStake * backOdds / (layOdds - commission).</div>
2822    `;
2823    const bo=qs('[data-ot="bo"]', rootEl), bs=qs('[data-ot="bs"]', rootEl), lo=qs('[data-ot="lo"]', rootEl), cI=qs('[data-ot="c"]', rootEl);
2824    function calc(){
2825      setInlineWarn(rootEl, '');
2826      const BO=oddsToDec(bo.value), BS=num(bs.value), LO=oddsToDec(lo.value);
2827
2828      if (isBadStake(BS)){
2829        qs('[data-ot-k="ls"]', rootEl).textContent = '–';
2830        qs('[data-ot-k="liab"]', rootEl).textContent = '–';
2831        qs('[data-ot-k="prof"]', rootEl).textContent = '–';
2832        setInlineWarn(rootEl, 'Back stake cannot be negative.');
2833        return;
2834      }
2835
2836      if ((String(bo.value||'').trim() && Number.isFinite(BO) && BO <= 1) || (String(lo.value||'').trim() && Number.isFinite(LO) && LO <= 1)){
2837        qs('[data-ot-k="ls"]', rootEl).textContent = '–';
2838        qs('[data-ot-k="liab"]', rootEl).textContent = '–';
2839        qs('[data-ot-k="prof"]', rootEl).textContent = '–';
2840        setInlineWarn(rootEl, 'Odds must be greater than 1.00.');
2841        return;
2842      }
2843
2844      const C = (cI ? (num(cI.value) / 100) : 0);
2845
2846      if (Number.isFinite(C) && C < 0){
2847        qs('[data-ot-k="ls"]', rootEl).textContent = '–';
2848        qs('[data-ot-k="liab"]', rootEl).textContent = '–';
2849        qs('[data-ot-k="prof"]', rootEl).textContent = '–';
2850        setInlineWarn(rootEl, 'Commission cannot be negative.');
2851        return;
2852      }
2853
2854      if (Number.isFinite(BO)&&BO>1&&Number.isFinite(BS)&&BS>0&&Number.isFinite(LO)&&LO>1){
2855        const denom = (LO - (Number.isFinite(C) ? C : 0));
2856        if (denom <= 0){
2857          qs('[data-ot-k="ls"]', rootEl).textContent = '–';
2858          qs('[data-ot-k="liab"]', rootEl).textContent = '–';
2859          qs('[data-ot-k="prof"]', rootEl).textContent = '–';
2860          setInlineWarn(rootEl, 'Commission too high for these lay odds.');
2861          return;
2862        }
2863
2864        const LS = (BS*BO)/denom;
2865        const liab = LS*(LO-1);
2866
2867        // Profit if selection loses: lay win (after commission) minus back stake.
2868        const profLose = (LS * (1 - (Number.isFinite(C) ? C : 0))) - BS;
2869        // Profit if selection wins: back profit minus lay liability.
2870        const profWin = (BS*(BO-1)) - liab;
2871
2872        // With the equal-profit formula, profLose ~= profWin (small diffs only from rounding).
2873        const prof = profLose;
2874
2875        qs('[data-ot-k="ls"]', rootEl).textContent = fmtMoney(LS);
2876        qs('[data-ot-k="liab"]', rootEl).textContent = fmtMoney(liab);
2877        qs('[data-ot-k="prof"]', rootEl).textContent = fmtMoney(prof);
2878      } else {
2879        qs('[data-ot-k="ls"]', rootEl).textContent = '–';
2880        qs('[data-ot-k="liab"]', rootEl).textContent = '–';
2881        qs('[data-ot-k="prof"]', rootEl).textContent = '–';
2882      }
2883    }
2884    [bo,bs,lo,cI].filter(Boolean).forEach(el=>{
2885      el.addEventListener('input', calc);
2886      el.addEventListener('change', calc);
2887      el.addEventListener('paste', () => setTimeout(calc, 0));
2888    });
2889    setTimeout(calc, 0);
2890  }
2891
2892  function renderFreebet(rootEl){
2893    rootEl.innerHTML = `
2894      <div class="iceto-ot-row">
2895        <div class="iceto-ot-field">
2896          <label>Free bet stake</label>
2897          <input type="text" inputmode="decimal" placeholder="50" data-ot="fb">
2898        </div>
2899        <div class="iceto-ot-field">
2900          <label>Back odds (any format)</label>
2901          <input type="text" inputmode="decimal" placeholder="3.00" data-ot="bo" data-ot-odds="1">
2902        </div>
2903        <div class="iceto-ot-field">
2904          <label>Lay odds (any format)</label>
2905          <input type="text" inputmode="decimal" placeholder="3.10" data-ot="lo" data-ot-odds="1">
2906        </div>
2907        <div class="iceto-ot-field">
2908          <label>Commission % (optional)</label>
2909          <input type="text" inputmode="decimal" placeholder="0" data-ot="c">
2910        </div>
2911      </div>
2912      <div class="iceto-ot-warn" data-ot="warn" style="display:none;"></div>
2913      <div class="iceto-ot-kpis">
2914        <div class="iceto-ot-kpi"><div class="k">Lay stake (SNR)</div><div class="v" data-ot-k="ls">–</div></div>
2915        <div class="iceto-ot-kpi"><div class="k">Estimated cash value</div><div class="v" data-ot-k="cv">–</div></div>
2916      </div>
2917      <div class="iceto-ot-note">SNR free bet: stake is not returned. Commission % is applied to lay winnings. Equal profit uses layStake = freeBetStake * (backOdds-1) / (layOdds - commission).</div>
2918    `;
2919    const fb=qs('[data-ot="fb"]', rootEl), bo=qs('[data-ot="bo"]', rootEl), lo=qs('[data-ot="lo"]', rootEl), cI=qs('[data-ot="c"]', rootEl);
2920    function calc(){
2921      setInlineWarn(rootEl, '');
2922      const FB=num(fb.value), BO=oddsToDec(bo.value), LO=oddsToDec(lo.value);
2923
2924      if (isBadStake(FB)){
2925        qs('[data-ot-k="ls"]', rootEl).textContent = '–';
2926        qs('[data-ot-k="cv"]', rootEl).textContent = '–';
2927        setInlineWarn(rootEl, 'Free bet stake cannot be negative.');
2928        return;
2929      }
2930
2931      if ((String(bo.value||'').trim() && Number.isFinite(BO) && BO <= 1) || (String(lo.value||'').trim() && Number.isFinite(LO) && LO <= 1)){
2932        qs('[data-ot-k="ls"]', rootEl).textContent = '–';
2933        qs('[data-ot-k="cv"]', rootEl).textContent = '–';
2934        setInlineWarn(rootEl, 'Odds must be greater than 1.00.');
2935        return;
2936      }
2937
2938      const C = (cI ? (num(cI.value) / 100) : 0);
2939
2940      if (Number.isFinite(C) && C < 0){
2941        qs('[data-ot-k="ls"]', rootEl).textContent = '–';
2942        qs('[data-ot-k="cv"]', rootEl).textContent = '–';
2943        setInlineWarn(rootEl, 'Commission cannot be negative.');
2944        return;
2945      }
2946
2947      if (Number.isFinite(FB)&&FB>0&&Number.isFinite(BO)&&BO>1&&Number.isFinite(LO)&&LO>1){
2948        const denom = (LO - (Number.isFinite(C) ? C : 0));
2949        if (denom <= 0){
2950          qs('[data-ot-k="ls"]', rootEl).textContent = '–';
2951          qs('[data-ot-k="cv"]', rootEl).textContent = '–';
2952          setInlineWarn(rootEl, 'Commission too high for these lay odds.');
2953          return;
2954        }
2955
2956        const LS = (FB*(BO-1))/denom;
2957
2958        // Guaranteed cash value (equal profit), after commission on lay winnings.
2959        const cv = LS * (1 - (Number.isFinite(C) ? C : 0));
2960
2961        qs('[data-ot-k="ls"]', rootEl).textContent = fmtMoney(LS);
2962        qs('[data-ot-k="cv"]', rootEl).textContent = fmtMoney(cv);
2963      } else {
2964        qs('[data-ot-k="ls"]', rootEl).textContent = '–';
2965        qs('[data-ot-k="cv"]', rootEl).textContent = '–';
2966      }
2967    }
2968    [fb,bo,lo,cI].filter(Boolean).forEach(el=>{
2969      el.addEventListener('input', calc);
2970      el.addEventListener('change', calc);
2971    });
2972    setTimeout(calc, 0);
2973  }
2974
2975
2976
2977
2978})();
2979
2980

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.