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 = '×'; 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.