PageSourceSearch

https://souppe.ai/js/portfolio_chips.js?v=2

js souppe.ai collected 2026-10-05 12:39:12 UTC 31,102 bytes, 785 lines download raw bytes

1/* ============================================================
2   Souppe Chip Entry - the one-field portfolio entry.
3   ------------------------------------------------------------
4   Type "AAPL 30 MSFT 20": each token commits to a chip on space
5   or comma, every ticker is confirmed by company name, weights
6   left blank show the share they would receive, and a running
7   total sits with the analyze button.
8
9   This is the sibling of SouppePortfolioEntry (portfolio_entry.js,
10   the Country / Ticker / Weight row table). Both produce the same
11   [{ticker, country, weight}] and both are mounted by the same
12   pages - public/partials/features.php SOUPPE_PORTFOLIO_ENTRY
13   picks which one. Neither knows about the other.
14
15   Consumers: the staged landing hero (hero-demo.js), the retail
16   analyzer and the accredited workspace. Everything specific to
17   one of those - bot challenge, submit transport, result
18   rendering, example chips - stays in the caller and arrives
19   here as a callback.
20
21   Usage:
22     var entry = SouppeChipEntry.create({
23       box: el, chips: el, input: el,          // required
24       ac: el, note: el, names: el,            // optional
25       nameList: el, count: el, total: el,
26       capEmpty: el, capActive: el,
27       maxPositions: 30,
28       overflowMessage: 'Up to 30 positions.',
29       onChange: fn, onSubmit: fn, onNote: fn
30     });
31     entry.getPositions(); entry.populate(list); entry.setInputMode('amount');
32   ============================================================ */
33(function () {
34  'use strict';
35
36  var DEFAULT_SEARCH_URL = '/api/search_tickers.php';
37  var DEFAULT_VALIDATE_URL = '/api/validate_tickers.php';
38  var DEFAULT_COUNTRIES = ['US', 'UK', 'IL'];
39  var DEFAULT_SUFFIX_MAP = { US: '.US', UK: '.LSE', IL: '.TA' };
40
41  // A bare number typed straight after a position commits belongs to that
42  // position - "AAPL 30" - not to a new ticker called "30".
43  var VALUE_ONLY_RE = /^[0-9]+(?:[0-9,]*)(?:\.[0-9]+)?%?$/;
44
45  function create(opts) {
46    var el = {
47      box: opts.box,
48      chips: opts.chips,
49      input: opts.input,
50      ac: opts.ac || null,
51      note: opts.note || null,
52      names: opts.names || null,
53      nameList: opts.nameList || null,
54      count: opts.count || null,
55      total: opts.total || null,
56      capEmpty: opts.capEmpty || null,
57      capActive: opts.capActive || null
58    };
59    if (!el.box || !el.chips || !el.input) {
60      throw new Error('SouppeChipEntry.create: box, chips and input are required');
61    }
62
63    var COUNTRIES = opts.countries || DEFAULT_COUNTRIES;
64    var SUFFIX_MAP = opts.suffixMap || DEFAULT_SUFFIX_MAP;
65    var SEARCH_URL = opts.searchUrl || DEFAULT_SEARCH_URL;
66    var VALIDATE_URL = opts.validateUrl || DEFAULT_VALIDATE_URL;
67    var MAX_POSITIONS = opts.maxPositions || 30;
68    var OVERFLOW_MESSAGE = opts.overflowMessage
69      || ('Up to ' + MAX_POSITIONS + ' positions.');
70    var EMPTY_PLACEHOLDER = opts.placeholder || 'AAPL 30, MSFT 20…';
71
72    var onChange = (typeof opts.onChange === 'function') ? opts.onChange : function () {};
73    var onSubmit = (typeof opts.onSubmit === 'function') ? opts.onSubmit : function () {};
74    var onNote = (typeof opts.onNote === 'function') ? opts.onNote : null;
75
76    /* ── State ─────────────────────────────────────────────── */
77
78    var positions = [];            // [{ticker, country, weight|null}]
79    var inputMode = 'weight';      // 'weight' | 'amount'
80    var acState = { results: [], active: -1, open: false };
81
82    // `lastTouchedIndex` is the position addPosition() just created or
83    // updated; `pendingWeightFor` is the position a number typed next
84    // belongs to, and `pendingWeightText` is that number mid-keystroke.
85    var lastTouchedIndex = -1;
86    var pendingWeightFor = null;
87    var pendingWeightText = '';
88
89    // 'TICKER|COUNTRY' -> {name} | {error}. Same cache shape and endpoint as
90    // portfolio_entry.js, so a chip confirms a ticker exactly as a row does.
91    var nameCache = {};
92    var nameTimer = null;
93
94    // Marks applied from outside: missing tickers after a pre-flight, and
95    // the tracker's sandbox diff. Held by ticker so they survive a re-render.
96    var missingSet = {};
97    var syncMap = {};
98
99    /* ── Helpers ───────────────────────────────────────────── */
100
101    function note(html, kind) {
102      if (onNote) { onNote(html, kind); return; }
103      if (!el.note) return;
104      el.note.innerHTML = html || '';
105      el.note.className = 'demo-note' + (kind ? ' demo-note--' + kind : '');
106      el.note.hidden = !html;
107    }
108
109    function isAmount() {
110      return inputMode === 'amount';
111    }
112
113    // Percentages read as "30%"; amounts read as a plain grouped number,
114    // because the pages only promise that every amount is in the SAME
115    // currency - they never learn which one, so no symbol is invented.
116    function fmtValue(v) {
117      if (v === null || v === undefined || isNaN(v)) return '';
118      if (isAmount()) return Math.round(v * 100) / 100 === Math.round(v)
119        ? String(Math.round(v)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
120        : String(Math.round(v * 100) / 100).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
121      return (Math.round(v * 10) / 10) + '%';
122    }
123
124    function parseValue(raw) {
125      return parseFloat(String(raw).replace(/[%,\s]/g, ''));
126    }
127
128    function clampValue(v) {
129      if (isNaN(v)) return null;
130      // A weight is a share of 100. An amount has no ceiling we know of.
131      return isAmount() ? Math.max(0, v) : Math.max(0, Math.min(100, v));
132    }
133
134    /* ── Positions ─────────────────────────────────────────── */
135
136    function addPosition(ticker, country, weight, addOpts) {
137      ticker = String(ticker || '').trim().toUpperCase();
138      if (!ticker) return false;
139      if (positions.length >= MAX_POSITIONS) {
140        note(OVERFLOW_MESSAGE, 'info');
141        return false;
142      }
143      // Same-ticker+country replaces (value update), never duplicates.
144      for (var i = 0; i < positions.length; i++) {
145        if (positions[i].ticker === ticker && positions[i].country === country) {
146          positions[i].weight = (weight === null || weight === undefined)
147            ? positions[i].weight : weight;
148          lastTouchedIndex = i;
149          renderChips();
150          if (!addOpts || !addOpts.silent) onChange();
151          return true;
152        }
153      }
154      positions.push({
155        ticker: ticker,
156        country: country || 'US',
157        weight: (weight === undefined ? null : weight)
158      });
159      lastTouchedIndex = positions.length - 1;
160      renderChips();
161      if (!addOpts || !addOpts.silent) onChange();
162      return true;
163    }
164
165    function removePosition(index) {
166      positions.splice(index, 1);
167      clearPendingWeight();   // the indexes below it just shifted
168      renderChips();
169      renderNames();
170      onChange();
171    }
172
173    function cycleCountry(index) {
174      var pos = positions[index];
175      pos.country = COUNTRIES[(COUNTRIES.indexOf(pos.country) + 1) % COUNTRIES.length];
176      renderChips();
177      refreshNames();   // same symbol, different market, different company
178      onChange();
179    }
180
181    function setPositions(list) {
182      positions = [];
183      for (var i = 0; i < list.length && i < MAX_POSITIONS; i++) {
184        var p = list[i];
185        positions.push({
186          ticker: String(p.ticker || '').toUpperCase(),
187          country: COUNTRIES.indexOf(p.country) >= 0 ? p.country : 'US',
188          weight: (typeof p.weight === 'number' && !isNaN(p.weight)) ? p.weight : null
189        });
190      }
191      clearPendingWeight();
192      renderChips();
193      refreshNames();
194    }
195
196    /* ── Render ────────────────────────────────────────────── */
197
198    function renderChips() {
199      hideTip();   // the chip it was pointing at is about to be replaced
200      var shares = previewShares();
201      var frag = document.createDocumentFragment();
202      positions.forEach(function (pos, i) {
203        var chip = document.createElement('span');
204        chip.className = 'demo-pos';
205        var known = nameCache[pos.ticker + '|' + pos.country];
206        if (known && known.error) chip.className += ' demo-pos--unknown';
207        if (missingSet[pos.ticker]) chip.className += ' demo-pos--missing';
208        if (syncMap[pos.ticker] === 'new') chip.className += ' demo-pos--sync-new';
209        else if (syncMap[pos.ticker] === 'changed') chip.className += ' demo-pos--sync-changed';
210
211        // What the ticker actually is, on hover. Nothing to say while the
212        // lookup is still out - an empty data-tip shows no tooltip at all.
213        if (known && known.name) chip.setAttribute('data-tip', known.name);
214        else if (known && known.error) chip.setAttribute('data-tip', pos.ticker + ' — ' + known.error);
215        else if (known === undefined) chip.setAttribute('data-tip', 'Looking up ' + pos.ticker + '…');
216
217        var tk = document.createElement('span');
218        tk.className = 'demo-pos__ticker';
219        tk.textContent = pos.ticker;
220        chip.appendChild(tk);
221
222        var co = document.createElement('button');
223        co.type = 'button';
224        co.className = 'demo-pos__country'
225          + (pos.country === 'US' ? ' demo-pos__country--quiet' : '');
226        co.textContent = pos.country;
227        co.setAttribute('data-tip', 'Change market');
228        co.addEventListener('click', function () { cycleCountry(i); });
229        chip.appendChild(co);
230
231        var wt = document.createElement('input');
232        wt.className = 'demo-pos__weight';
233        wt.type = 'text';
234        wt.inputMode = 'decimal';
235        wt.setAttribute('aria-label', pos.ticker + (isAmount() ? ' amount' : ' weight percent'));
236        // Three states: the number you typed, the number you are typing right
237        // now, or - when you have set none - the share this position would get.
238        // That last one is a weight-mode idea only: a blank amount cannot be
239        // inferred from the others, so in amount mode it stays blank.
240        if (i === pendingWeightFor && pendingWeightText !== '') {
241          wt.value = pendingWeightText + (isAmount() ? '' : '%');
242          chip.className += ' demo-pos--typing';
243        } else if (pos.weight === null || pos.weight === undefined) {
244          wt.value = '';
245          wt.placeholder = (shares && !isAmount()) ? fmtValue(shares.each) : (isAmount() ? '0' : '%');
246          if (!isAmount()) wt.className += ' demo-pos__weight--ghost';
247        } else {
248          wt.value = fmtValue(pos.weight);
249          wt.placeholder = isAmount() ? '0' : '%';
250        }
251        wt.addEventListener('change', function () {
252          pos.weight = clampValue(parseValue(wt.value));
253          clearPendingWeight();
254          renderChips();   // every other ghost re-splits around this number
255          onChange();
256        });
257        wt.addEventListener('keydown', function (e) {
258          if (e.key === 'Enter') { e.preventDefault(); wt.blur(); onSubmit(); }
259        });
260        chip.appendChild(wt);
261
262        var rm = document.createElement('button');
263        rm.type = 'button';
264        rm.className = 'demo-pos__remove';
265        rm.innerHTML = '&times;';
266        rm.setAttribute('aria-label', 'Remove ' + pos.ticker);
267        rm.addEventListener('click', function () { removePosition(i); });
268        chip.appendChild(rm);
269
270        frag.appendChild(chip);
271      });
272      el.chips.innerHTML = '';
273      el.chips.appendChild(frag);
274      el.box.classList.toggle('demo-box--has-positions', positions.length > 0);
275      el.input.placeholder = positions.length === 0 ? EMPTY_PLACEHOLDER : 'Add a ticker…';
276      renderMeta(shares);
277    }
278
279    /* ── Value preview ─────────────────────────────────────── */
280
281    // What an even split WOULD give the blanks, without writing to
282    // positions. Same maths as balanceWeights(), read-only, so the split is
283    // shown on the chips as the portfolio is built rather than announced
284    // after the analysis has already run. Weight mode only.
285    function previewShares() {
286      if (isAmount() || positions.length === 0) return null;
287      var explicit = 0;
288      var missing = 0;
289      positions.forEach(function (p) {
290        if (p.weight === null || p.weight === undefined) missing++;
291        else explicit += p.weight;
292      });
293      if (missing === 0) return null;
294      var pool = Math.max(0, 100 - explicit);
295      var each = pool > 0 ? pool / missing : 100 / positions.length;
296      // `each` is rounded for the chip; `raw` is not, because summing thirty
297      // rounded shares reports 99.9% for a portfolio that is exactly whole.
298      return { each: Math.round(each * 100) / 100, raw: each };
299    }
300
301    // Fills only the blanks, never a number the visitor typed. Callers
302    // normalise the total to 100 on their side, so what the chips show is
303    // exactly what gets scored, in every case.
304    function balanceWeights() {
305      if (isAmount()) return false;
306      var shares = previewShares();
307      if (!shares) return false;
308      positions.forEach(function (p) {
309        if (p.weight === null || p.weight === undefined) p.weight = shares.each;
310      });
311      renderChips();
312      return true;
313    }
314
315    /* ── The line under the box: names, count, running total ── */
316
317    function renderMeta(shares) {
318      var has = positions.length > 0;
319      if (el.capEmpty) el.capEmpty.hidden = has;
320      if (el.capActive) el.capActive.hidden = !has || isAmount();
321      if (el.count) {
322        el.count.textContent = has
323          ? positions.length + (positions.length === 1 ? ' position' : ' positions')
324          : '';
325      }
326      if (el.names) el.names.hidden = !has;
327      renderTotal(shares, has);
328    }
329
330    // The running total, beside the analyze button: the sum of every value
331    // as it stands, counting the even split the blanks are showing. In
332    // weight mode it can only fall short of 100 once every position carries
333    // a number of its own - which is exactly when the visitor wants to know.
334    // In amount mode there is no target, so it reports and does not judge.
335    function renderTotal(shares, has) {
336      if (!el.total) return;
337      el.total.hidden = !has;
338      if (!has) return;
339      var sum = 0;
340      positions.forEach(function (p) {
341        if (p.weight === null || p.weight === undefined) sum += shares ? shares.raw : 0;
342        else sum += p.weight;
343      });
344      if (isAmount()) {
345        el.total.textContent = fmtValue(sum);
346        el.total.className = 'demo-box__total demo-box__total--plain';
347        return;
348      }
349      sum = Math.round(sum * 10) / 10;
350      el.total.textContent = sum + '%';
351      el.total.className = 'demo-box__total ' + (
352        sum >= 90 ? 'demo-box__total--full'
353          : sum >= 50 ? 'demo-box__total--part'
354            : 'demo-box__total--low'
355      );
356    }
357
358    function renderNames() {
359      if (!el.nameList) return;
360      el.nameList.innerHTML = '';
361      positions.forEach(function (pos) {
362        var entry = nameCache[pos.ticker + '|' + pos.country];
363        if (!entry || (!entry.name && !entry.error)) return;
364        if (el.nameList.childNodes.length) {
365          el.nameList.appendChild(document.createTextNode(' · '));
366        }
367        var span = document.createElement('span');
368        // A name confirms, a reason warns - they cannot look the same.
369        span.className = entry.error ? 'demo-names__miss' : 'demo-names__ok';
370        span.textContent = entry.error ? (pos.ticker + ' — ' + entry.error) : entry.name;
371        el.nameList.appendChild(span);
372      });
373    }
374
375    // One batched lookup per burst of commits, for tickers not already
376    // cached. Best-effort throughout: a failure leaves the chip unmarked
377    // rather than accusing a real ticker of not existing.
378    function refreshNames() {
379      clearTimeout(nameTimer);
380      nameTimer = setTimeout(lookupNames, 250);
381    }
382
383    function lookupNames() {
384      var want = [];
385      positions.forEach(function (pos) {
386        if (nameCache[pos.ticker + '|' + pos.country] === undefined) {
387          want.push({ ticker: pos.ticker, country: pos.country });
388        }
389      });
390      if (want.length === 0) { renderNames(); return; }
391      var xhr = new XMLHttpRequest();
392      xhr.open('POST', VALIDATE_URL, true);
393      xhr.setRequestHeader('Content-Type', 'application/json');
394      xhr.timeout = 10000;
395      xhr.onload = function () {
396        try {
397          var resp = JSON.parse(xhr.responseText);
398          var reasons = {};
399          (resp.missing_tickers || []).forEach(function (m) { reasons[m.ticker] = m.reason; });
400          want.forEach(function (w) {
401            var key = w.ticker + '|' + w.country;
402            if (resp.valid_names && resp.valid_names[w.ticker]) {
403              nameCache[key] = { name: resp.valid_names[w.ticker] };
404            } else if (reasons[w.ticker]) {
405              nameCache[key] = { error: reasons[w.ticker] };
406            } else {
407              nameCache[key] = { name: '' };   // upstream skipped it - say nothing
408            }
409          });
410        } catch (parse_err) {
411          want.forEach(function (w) { nameCache[w.ticker + '|' + w.country] = { name: '' }; });
412        }
413        renderChips();
414        renderNames();
415      };
416      xhr.onerror = function () {};
417      xhr.ontimeout = function () {};
418      xhr.send(JSON.stringify({ positions: want }));
419    }
420
421    /* ── Tooltip ───────────────────────────────────────────
422       One themed bubble for the whole box, driven by [data-tip]. The
423       browser's own tooltip is slow, unstyled and cannot carry what we know
424       - a company name that resolves a second after the chip appears. Hover
425       and keyboard focus only: a touch device has no hover to reveal it
426       with, so it gets nothing rather than a bubble it cannot dismiss. */
427
428    var tip = null;
429    var canHover = !window.matchMedia
430      || window.matchMedia('(hover: hover) and (pointer: fine)').matches;
431
432    function tipHost() {
433      return el.box.parentNode;
434    }
435
436    function showTip(target) {
437      var text = target.getAttribute('data-tip');
438      if (!text) return;
439      if (!tip) {
440        tip = document.createElement('div');
441        tip.className = 'demo-tip';
442        tip.setAttribute('role', 'tooltip');
443        tipHost().appendChild(tip);
444      }
445      tip.textContent = text;
446      tip.classList.add('demo-tip--on');
447
448      // Centred over the target, then clamped inside the host so a chip at
449      // either edge never pushes the bubble off it.
450      var host = tipHost().getBoundingClientRect();
451      var r = target.getBoundingClientRect();
452      var left = (r.left - host.left) + (r.width / 2) - (tip.offsetWidth / 2);
453      tip.style.left = Math.max(0, Math.min(left, host.width - tip.offsetWidth)) + 'px';
454      tip.style.top = ((r.top - host.top) - tip.offsetHeight - 8) + 'px';
455    }
456
457    function hideTip() {
458      if (tip) tip.classList.remove('demo-tip--on');
459    }
460
461    function tipTarget(node) {
462      return (node && node.closest) ? node.closest('[data-tip]') : null;
463    }
464
465    if (canHover) {
466      el.box.addEventListener('mouseover', function (e) {
467        var t = tipTarget(e.target);
468        if (t) showTip(t);
469      });
470      el.box.addEventListener('mouseout', function (e) {
471        var from = tipTarget(e.target);
472        // Moving between children of the same target is not a leave.
473        if (from && from !== tipTarget(e.relatedTarget)) hideTip();
474      });
475      el.box.addEventListener('focusin', function (e) {
476        var t = tipTarget(e.target);
477        if (t) showTip(t);
478      });
479      el.box.addEventListener('focusout', hideTip);
480    }
481
482    /* ── Typed entry + autocomplete ────────────────────────── */
483
484    function parseTypedEntry(text) {
485      // "AAPL", "AAPL 30", "AAPL 30%", "BP.LSE 20"
486      var m = String(text).trim().toUpperCase()
487        .match(/^([A-Z0-9.\-]{1,20})(?:[\s,]+([0-9]+(?:\.[0-9]+)?)%?)?$/);
488      if (!m) return null;
489      var ticker = m[1];
490      var weight = m[2] !== undefined ? parseFloat(m[2]) : null;
491      var country = 'US';
492      for (var sfx in SUFFIX_MAP) {
493        if (ticker.endsWith(SUFFIX_MAP[sfx])) {
494          country = sfx;
495          ticker = ticker.slice(0, -SUFFIX_MAP[sfx].length);
496          break;
497        }
498      }
499      return { ticker: ticker, country: country, weight: weight };
500    }
501
502    function commitInput() {
503      var parsed = parseTypedEntry(el.input.value);
504      if (!parsed || !parsed.ticker) return false;
505      if (addPosition(parsed.ticker, parsed.country, parsed.weight)) {
506        el.input.value = '';
507        closeAc();
508        // "AAPL " then "30" is one position, not two. Only a position that
509        // arrived without a value can still receive one this way.
510        pendingWeightFor = (parsed.weight === null) ? lastTouchedIndex : null;
511        pendingWeightText = '';
512        refreshNames();
513        return true;
514      }
515      return false;
516    }
517
518    function clearPendingWeight() {
519      pendingWeightFor = null;
520      pendingWeightText = '';
521    }
522
523    function applyPendingWeight() {
524      var pos = positions[pendingWeightFor];
525      var v = clampValue(parseValue(el.input.value));
526      if (pos && v !== null) {
527        pos.weight = v;
528        onChange();
529      }
530      el.input.value = '';
531      clearPendingWeight();
532      closeAc();
533      renderChips();
534    }
535
536    // True while the field holds a bare number that belongs to the position
537    // just committed - the one case where the text is not a new ticker.
538    function typingPendingValue() {
539      return pendingWeightFor !== null
540        && positions[pendingWeightFor] !== undefined
541        && VALUE_ONLY_RE.test(el.input.value.trim());
542    }
543
544    var acTimer = null;
545    function scheduleSearch() {
546      clearTimeout(acTimer);
547      acTimer = setTimeout(doSearch, 300);
548    }
549
550    function doSearch() {
551      if (!el.ac) return;
552      var parsed = parseTypedEntry(el.input.value.trim());
553      // Only suggest while typing the ticker part (no value yet).
554      var query = parsed && parsed.weight === null ? parsed.ticker : '';
555      if (query.length < 1) { closeAc(); return; }
556      var xhr = new XMLHttpRequest();
557      xhr.open('POST', SEARCH_URL, true);
558      xhr.setRequestHeader('Content-Type', 'application/json');
559      xhr.timeout = 5000;
560      xhr.onload = function () {
561        try {
562          var resp = JSON.parse(xhr.responseText);
563          var current = parseTypedEntry(el.input.value.trim());
564          if (resp.results && current && current.ticker.toUpperCase() === query.toUpperCase()) {
565            renderAc(resp.results);
566          }
567        } catch (parse_err) { /* autocomplete is best-effort */ }
568      };
569      xhr.onerror = function () {};
570      xhr.ontimeout = function () {};
571      xhr.send(JSON.stringify({ query: query, country: 'US', limit: 8 }));
572    }
573
574    function renderAc(results) {
575      acState.results = results.slice(0, 8);
576      acState.active = -1;
577      if (!acState.results.length) { closeAc(); return; }
578      el.ac.innerHTML = '';
579      acState.results.forEach(function (r, i) {
580        var item = document.createElement('button');
581        item.type = 'button';
582        item.className = 'demo-ac__item';
583        var t = document.createElement('span');
584        t.className = 'demo-ac__ticker';
585        t.textContent = r.ticker;
586        var n = document.createElement('span');
587        n.className = 'demo-ac__name';
588        n.textContent = r.name || '';
589        item.appendChild(t);
590        item.appendChild(n);
591        item.addEventListener('click', function () { pickAc(i); });
592        el.ac.appendChild(item);
593      });
594      el.ac.hidden = false;
595      acState.open = true;
596    }
597
598    function highlightAc(idx) {
599      var items = el.ac.querySelectorAll('.demo-ac__item');
600      items.forEach(function (item, i) {
601        item.classList.toggle('demo-ac__item--active', i === idx);
602      });
603    }
604
605    function pickAc(i) {
606      var r = acState.results[i];
607      if (!r) return;
608      addPosition(r.ticker, 'US', null);
609      el.input.value = '';
610      closeAc();
611      // Picked from the list, so the name is already known - cache it rather
612      // than asking the validate endpoint for something we were just told.
613      nameCache[r.ticker.toUpperCase() + '|US'] = { name: r.name || '' };
614      pendingWeightFor = lastTouchedIndex;
615      pendingWeightText = '';
616      renderChips();
617      renderNames();
618      el.input.focus();
619    }
620
621    function closeAc() {
622      if (!el.ac) return;
623      el.ac.hidden = true;
624      acState.open = false;
625      acState.active = -1;
626    }
627
628    el.input.addEventListener('input', function () {
629      if (pendingWeightFor !== null) {
630        var raw = el.input.value.trim();
631        if (raw === '' || VALUE_ONLY_RE.test(raw)) {
632          // Show the number on its chip as it is typed, never in the ticker
633          // autocomplete - "30" is a value here, not a symbol to search for.
634          pendingWeightText = raw.replace(/[%,]/g, '');
635          closeAc();
636          renderChips();
637          return;
638        }
639        clearPendingWeight();
640        renderChips();
641      }
642      scheduleSearch();
643    });
644
645    el.input.addEventListener('keydown', function (e) {
646      if (acState.open && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
647        e.preventDefault();
648        var n = acState.results.length;
649        acState.active = e.key === 'ArrowDown'
650          ? Math.min(acState.active + 1, n - 1)
651          : Math.max(acState.active - 1, 0);
652        highlightAc(acState.active);
653        return;
654      }
655      if (e.key === 'Enter') {
656        e.preventDefault();
657        if (acState.open && acState.active >= 0) { pickAc(acState.active); return; }
658        if (typingPendingValue()) { applyPendingWeight(); return; }
659        if (el.input.value.trim() !== '') { commitInput(); return; }
660        onSubmit();
661        return;
662      }
663      // Space and comma both end a token. Which of the two the visitor uses
664      // is their habit, not a rule they should have to learn: "AAPL MSFT",
665      // "AAPL 30 MSFT 20" and "AAPL, MSFT" all mean what they look like.
666      if (e.key === ',' || e.key === ' ') {
667        e.preventDefault();
668        if (typingPendingValue()) { applyPendingWeight(); return; }
669        commitInput();
670        return;
671      }
672      if (e.key === 'Escape') closeAc();
673      if (e.key === 'Backspace' && el.input.value === '' && positions.length > 0) {
674        removePosition(positions.length - 1);
675      }
676    });
677
678    el.input.addEventListener('blur', function () {
679      setTimeout(function () {
680        closeAc();
681        if (el.input.value.trim() === '') return;
682        if (typingPendingValue()) { applyPendingWeight(); return; }
683        commitInput();
684      }, 150);
685    });
686
687    // Clicking anywhere in the box puts the caret in the field, so the whole
688    // card behaves like the one input it looks like.
689    el.box.addEventListener('click', function (e) {
690      if (e.target === el.box || e.target === el.chips) el.input.focus();
691    });
692
693    /* ── Paste ─────────────────────────────────────────────── */
694
695    el.box.addEventListener('paste', function (e) {
696      var text = (e.clipboardData || window.clipboardData).getData('text');
697      if (!text || (text.indexOf('\n') === -1 && text.indexOf('\t') === -1 && text.indexOf(',') === -1)) {
698        return;   // single token - let the input handle it
699      }
700      if (typeof PortfolioInterpreter === 'undefined') return;
701      e.preventDefault();
702      try {
703        var out = PortfolioInterpreter.interpret(text, 'US', 'paste.csv');
704        if (out.positions && out.positions.length > 0) {
705          var clipped = out.positions.length > MAX_POSITIONS;
706          setPositions(out.positions);
707          onChange();
708          note(
709            'Read ' + Math.min(out.positions.length, MAX_POSITIONS) + ' positions from the paste.'
710            + (clipped ? ' ' + OVERFLOW_MESSAGE : ''),
711            'info'
712          );
713          el.input.value = '';
714        }
715      } catch (paste_err) {
716        note('That paste did not read as a portfolio. Type tickers instead: AAPL 30, MSFT 20.', 'warn');
717      }
718    });
719
720    /* ── Public surface ────────────────────────────────────── */
721
722    return {
723      // Read / write, the same shape the row table produces.
724      getPositions: function () {
725        return positions.map(function (p) {
726          return {
727            ticker: p.ticker,
728            country: p.country,
729            weight: (p.weight === null || p.weight === undefined) ? NaN : p.weight
730          };
731        });
732      },
733      populate: function (list) { setPositions(list || []); },
734      addPosition: addPosition,
735      clear: function () {
736        positions = [];
737        missingSet = {};
738        syncMap = {};
739        clearPendingWeight();
740        el.input.value = '';
741        closeAc();
742        renderChips();
743        renderNames();
744      },
745      count: function () { return positions.length; },
746      focus: function () { el.input.focus(); },
747
748      // Commit whatever is still sitting in the field. Enter does this on
749      // its way through, but a click on the analyze button never touches the
750      // input's handlers - without this, a typed-but-uncommitted ticker
751      // would be silently dropped from the analysis.
752      flush: function () {
753        if (typingPendingValue()) { applyPendingWeight(); return; }
754        if (el.input.value.trim() !== '') commitInput();
755      },
756
757      // Fill the blanks before submit, the way the caller's own
758      // normalisation expects a complete set of numbers.
759      balanceWeights: balanceWeights,
760
761      // Marks owned by the page: pre-flight misses and the tracker's diff.
762      markMissing: function (missingArray) {
763        missingSet = {};
764        (missingArray || []).forEach(function (m) {
765          var t = (m && m.ticker ? m.ticker : m);
766          if (t) missingSet[String(t).toUpperCase()] = true;
767        });
768        renderChips();
769      },
770      clearMissing: function () { missingSet = {}; renderChips(); },
771      markSync: function (map) { syncMap = map || {}; renderChips(); },
772      clearSync: function () { syncMap = {}; renderChips(); },
773
774      setInputMode: function (mode) {
775        if (mode !== 'weight' && mode !== 'amount') return;
776        inputMode = mode;
777        renderChips();
778      },
779      getInputMode: function () { return inputMode; },
780      refreshNames: refreshNames
781    };
782  }
783
784  window.SouppeChipEntry = { create: create };
785})();

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.