decred.org/dcrdex@v1.0.3/client/webserver/site/src/js/doc.ts (about) 1 import * as intl from './locales' 2 import { 3 UnitInfo, 4 LayoutMetrics, 5 WalletState, 6 PageElement 7 } from './registry' 8 import State from './state' 9 10 // Symbolizer is satisfied by both dex.Asset and core.SupportedAsset. Used by 11 // Doc.symbolize. 12 interface Symbolizer { 13 symbol: string 14 unitInfo: UnitInfo 15 } 16 17 const parser = new window.DOMParser() 18 19 const FPS = 30 20 21 const BipIDs: Record<number, string> = { 22 0: 'btc', 23 42: 'dcr', 24 2: 'ltc', 25 5: 'dash', 26 20: 'dgb', 27 22: 'mona', 28 28: 'vtc', 29 3: 'doge', 30 145: 'bch', 31 60: 'eth', 32 60001: 'usdc.eth', 33 60002: 'usdt.eth', 34 60003: 'matic.eth', 35 136: 'firo', 36 133: 'zec', 37 966: 'polygon', 38 966001: 'usdc.polygon', 39 966002: 'weth.polygon', 40 966003: 'wbtc.polygon', 41 966004: 'usdt.polygon', 42 147: 'zcl' 43 } 44 45 const BipSymbolIDs: Record<string, number> = {}; 46 (function () { 47 for (const k of Object.keys(BipIDs)) { 48 BipSymbolIDs[BipIDs[parseInt(k)]] = parseInt(k) 49 } 50 })() 51 52 const BipSymbols = Object.values(BipIDs) 53 54 const RateEncodingFactor = 1e8 // same as value defined in ./orderutil 55 56 const log10RateEncodingFactor = Math.round(Math.log10(RateEncodingFactor)) 57 58 const languages = navigator.languages.filter((locale: string) => locale !== 'c') 59 60 const intFormatter = new Intl.NumberFormat(languages, { maximumFractionDigits: 0 }) 61 62 const fourSigFigs = new Intl.NumberFormat(languages, { 63 minimumSignificantDigits: 4, 64 maximumSignificantDigits: 4 65 }) 66 67 /* A cache for formatters used for Doc.formatCoinValue. */ 68 const decimalFormatters: Record<number, Intl.NumberFormat> = {} 69 70 /* 71 * decimalFormatter gets the formatCoinValue formatter for the specified decimal 72 * precision. 73 */ 74 function decimalFormatter (prec: number) { 75 return formatter(decimalFormatters, 2, prec) 76 } 77 78 /* A cache for formatters used for Doc.formatFullPrecision. */ 79 const fullPrecisionFormatters: Record<number, Intl.NumberFormat> = {} 80 81 /* 82 * fullPrecisionFormatter gets the formatFullPrecision formatter for the 83 * specified decimal precision. 84 */ 85 function fullPrecisionFormatter (prec: number, locales?: string | string[]) { 86 return formatter(fullPrecisionFormatters, prec, prec, locales) 87 } 88 89 /* 90 * formatter gets the formatter from the supplied cache if it already exists, 91 * else creates it. 92 */ 93 function formatter (formatters: Record<string, Intl.NumberFormat>, min: number, max: number, locales?: string | string[]): Intl.NumberFormat { 94 const k = `${min}-${max}` 95 let fmt = formatters[k] 96 if (!fmt) { 97 fmt = new Intl.NumberFormat(locales ?? languages, { 98 minimumFractionDigits: min, 99 maximumFractionDigits: max 100 }) 101 formatters[k] = fmt 102 } 103 return fmt 104 } 105 106 /* 107 * convertToConventional converts the value in atomic units to conventional 108 * units. 109 */ 110 function convertToConventional (v: number, unitInfo?: UnitInfo) { 111 let prec = 8 112 if (unitInfo) { 113 const f = unitInfo.conventional.conversionFactor 114 v /= f 115 prec = Math.round(Math.log10(f)) 116 } 117 return [v, prec] 118 } 119 120 /* 121 * bestDisplayOrder is used in bestConversion, and is the order of magnitude 122 * that is considered the best for display. For example, if bestDisplayOrder is 123 * 1, and the choices for display are 1,000 BTC or 0.00001 Sats, the algorithm 124 * will look at the orders of the conversions, 1000 => 10^3 => order 3, and 125 * 0.00001 => 10^-5 => order 5, and see which is closest to bestDisplayOrder and 126 * choose that conversion. In the example, 3 - bestDisplayOrder = 2 and 127 * 1 - (-5) = 6, so the conversion that has the order closest to 128 * bestDisplayOrder is the first one, 1,000 BTC. 129 */ 130 const bestDisplayOrder = 1 // 10^1 => 1 131 132 /* 133 * resolveUnitConversions creates a lookup object mapping unit -> conversion 134 * factor. By default, resolveUnitConversions only maps the atomic and 135 * conventional units. If a prefs dict is provided, additional units can be 136 * included. 137 */ 138 function resolveUnitConversions (ui: UnitInfo, prefs?: Record<string, boolean>): Record<string, number> { 139 const unitFactors: Record<string, number> = { 140 [ui.atomicUnit]: 1, 141 [ui.conventional.unit]: ui.conventional.conversionFactor 142 } 143 if (ui.denominations && prefs) { 144 for (const alt of ui.denominations) if (prefs[alt.unit]) unitFactors[alt.unit] = alt.conversionFactor 145 } 146 return unitFactors 147 } 148 149 // Helpers for working with the DOM. 150 export default class Doc { 151 /* 152 * idel is the element with the specified id that is the descendent of the 153 * specified node. 154 */ 155 static idel (el: Document | Element, id: string): HTMLElement { 156 return el.querySelector(`#${id}`) as HTMLElement 157 } 158 159 /* bind binds the function to the event for the element. */ 160 static bind (el: EventTarget, ev: string | string[], f: EventListenerOrEventListenerObject, opts?: any /* EventListenerOptions */): void { 161 for (const e of (Array.isArray(ev) ? ev : [ev])) el.addEventListener(e, f, opts) 162 } 163 164 /* unbind removes the handler for the event from the element. */ 165 static unbind (el: EventTarget, ev: string, f: (e: Event) => void): void { 166 el.removeEventListener(ev, f) 167 } 168 169 /* noderize creates a Document object from a string of HTML. */ 170 static noderize (html: string): Document { 171 return parser.parseFromString(html, 'text/html') 172 } 173 174 /* 175 * mouseInElement returns true if the position of mouse event, e, is within 176 * the bounds of the specified element or any of its descendents. 177 */ 178 static mouseInElement (e: MouseEvent, el: HTMLElement): boolean { 179 if (el.contains(e.target as Node)) return true 180 const rect = el.getBoundingClientRect() 181 return e.pageX >= rect.left && e.pageX <= rect.right && 182 e.pageY >= rect.top && e.pageY <= rect.bottom 183 } 184 185 /* 186 * layoutMetrics gets information about the elements position on the page. 187 */ 188 static layoutMetrics (el: HTMLElement): LayoutMetrics { 189 const box = el.getBoundingClientRect() 190 const docEl = document.documentElement 191 const top = box.top + docEl.scrollTop 192 const left = box.left + docEl.scrollLeft 193 const w = el.offsetWidth 194 const h = el.offsetHeight 195 return { 196 bodyTop: top, 197 bodyLeft: left, 198 width: w, 199 height: h, 200 centerX: left + w / 2, 201 centerY: top + h / 2 202 } 203 } 204 205 static descendentMetrics (parent: PageElement, kid: PageElement): LayoutMetrics { 206 const parentMetrics = Doc.layoutMetrics(parent) 207 const kidMetrics = Doc.layoutMetrics(kid) 208 return { 209 bodyTop: kidMetrics.bodyTop - parentMetrics.bodyTop, 210 bodyLeft: kidMetrics.bodyLeft - parentMetrics.bodyLeft, 211 width: kidMetrics.width, 212 height: kidMetrics.height, 213 centerX: kidMetrics.centerX - parentMetrics.bodyLeft, 214 centerY: kidMetrics.centerY - parentMetrics.bodyTop 215 } 216 } 217 218 /* empty removes all child nodes from the specified element. */ 219 static empty (...els: Element[]) { 220 for (const el of els) while (el.firstChild) el.removeChild(el.firstChild) 221 } 222 223 /* 224 * setContent removes all child nodes from the specified element and appends 225 * passed elements. 226 */ 227 static setContent (ancestor: PageElement, ...kids: PageElement[]) { 228 Doc.empty(ancestor) 229 for (const k of kids) ancestor.appendChild(k) 230 } 231 232 /* 233 * hide hides the specified elements. This is accomplished by adding the 234 * bootstrap d-hide class to the element. Use Doc.show to undo. 235 */ 236 static hide (...els: Element[]) { 237 for (const el of els) el.classList.add('d-hide') 238 } 239 240 /* 241 * show shows the specified elements. This is accomplished by removing the 242 * bootstrap d-hide class as added with Doc.hide. 243 */ 244 static show (...els: Element[]) { 245 for (const el of els) el.classList.remove('d-hide') 246 } 247 248 /* 249 * showTemporarily shows the specified elements for the specified time, then 250 * hides it again. 251 */ 252 static showTemporarily (timeout: number, ...els: Element[]) { 253 this.show(...els) 254 setTimeout(() => { 255 this.hide(...els) 256 }, timeout) 257 } 258 259 /* 260 * show or hide the specified elements, based on value of the truthiness of 261 * vis. 262 */ 263 static setVis (vis: any, ...els: Element[]) { 264 if (vis) Doc.show(...els) 265 else Doc.hide(...els) 266 } 267 268 /* isHidden returns true if the specified element is hidden */ 269 static isHidden (el: Element): boolean { 270 return el.classList.contains('d-hide') 271 } 272 273 /* isDisplayed returns true if the specified element is not hidden */ 274 static isDisplayed (el: Element): boolean { 275 return !el.classList.contains('d-hide') 276 } 277 278 /* 279 * animate runs the supplied function, which should be a "progress" function 280 * accepting one argument. The progress function will be called repeatedly 281 * with the argument varying from 0.0 to 1.0. The exact path that animate 282 * takes from 0.0 to 1.0 will vary depending on the choice of easing 283 * algorithm. See the Easing object for the available easing algo choices. The 284 * default easing algorithm is linear. 285 */ 286 static async animate (duration: number, f: (progress: number) => void, easingAlgo?: string) { 287 await new Animation(duration, f, easingAlgo).wait() 288 } 289 290 static async blink (el: PageElement) { 291 const [r, g, b] = State.isDark() ? [255, 255, 255] : [0, 0, 0] 292 const cycles = 2 293 Doc.animate(1000, (p: number) => { 294 el.style.outline = `2px solid rgba(${r}, ${g}, ${b}, ${(cycles - p * cycles) % 1})` 295 }) 296 } 297 298 static applySelector (ancestor: HTMLElement, k: string): PageElement[] { 299 return Array.from(ancestor.querySelectorAll(k)) as PageElement[] 300 } 301 302 static kids (ancestor: HTMLElement): PageElement[] { 303 return Array.from(ancestor.children) as PageElement[] 304 } 305 306 static safeSelector (ancestor: HTMLElement, k: string): PageElement { 307 const el = ancestor.querySelector(k) 308 if (el) return el as PageElement 309 console.warn(`no element found for selector '${k}' on element ->`, ancestor) 310 return document.createElement('div') 311 } 312 313 /* 314 * idDescendants creates an object mapping to elements which are descendants 315 * of the ancestor and have id attributes. Elements are keyed by their id 316 * value. 317 */ 318 static idDescendants (ancestor: HTMLElement): Record<string, PageElement> { 319 const d: Record<string, PageElement> = {} 320 for (const el of Doc.applySelector(ancestor, '[id]')) d[el.id] = el 321 return d 322 } 323 324 /* 325 * formatCoinValue formats the value in atomic units into a string 326 * representation in conventional units. If the value happens to be an 327 * integer, no decimals are displayed. Trailing zeros may be truncated. 328 */ 329 static formatCoinValue (vAtomic: number, unitInfo?: UnitInfo): string { 330 const [v, prec] = convertToConventional(vAtomic, unitInfo) 331 if (Number.isInteger(v)) return intFormatter.format(v) 332 return decimalFormatter(prec).format(v) 333 } 334 335 static conventionalCoinValue (vAtomic: number, unitInfo?: UnitInfo): number { 336 const [v] = convertToConventional(vAtomic, unitInfo) 337 return v 338 } 339 340 /* 341 * formatRateFullPrecision formats rate to represent it exactly at rate step 342 * precision, trimming non-effectual zeros if there are any. 343 */ 344 static formatRateFullPrecision (encRate: number, bui: UnitInfo, qui: UnitInfo, rateStepEnc: number) { 345 const r = bui.conventional.conversionFactor / qui.conventional.conversionFactor 346 const convRate = encRate * r / RateEncodingFactor 347 const rateStepDigits = log10RateEncodingFactor - Math.floor(Math.log10(rateStepEnc)) - 348 Math.floor(Math.log10(bui.conventional.conversionFactor) - Math.log10(qui.conventional.conversionFactor)) 349 if (rateStepDigits <= 0) return intFormatter.format(convRate) 350 return fullPrecisionFormatter(rateStepDigits).format(convRate) 351 } 352 353 static formatFourSigFigs (n: number, maxDecimals?: number): string { 354 return formatSigFigsWithFormatters(intFormatter, fourSigFigs, n, maxDecimals) 355 } 356 357 static formatInt (i: number): string { 358 return intFormatter.format(i) 359 } 360 361 /* 362 * formatFullPrecision formats the value in atomic units into a string 363 * representation in conventional units using the full decimal precision 364 * associated with the conventional unit's conversion factor. 365 */ 366 static formatFullPrecision (vAtomic: number, unitInfo?: UnitInfo): string { 367 const [v, prec] = convertToConventional(vAtomic, unitInfo) 368 return fullPrecisionFormatter(prec).format(v) 369 } 370 371 /* 372 * formatFiatConversion formats the value in atomic units to its representation in 373 * conventional units and returns the fiat value as a string. 374 */ 375 static formatFiatConversion (vAtomic: number, rate: number, unitInfo?: UnitInfo): string { 376 if (!rate || rate === 0) return intl.prep(intl.ID_UNAVAILABLE) 377 const prec = 2 378 const [v] = convertToConventional(vAtomic, unitInfo) 379 const value = v * rate 380 return fullPrecisionFormatter(prec).format(value) 381 } 382 383 static languages (): string[] { 384 return languages 385 } 386 387 static formatFiatValue (value: number): string { 388 return fullPrecisionFormatter(2).format(value) 389 } 390 391 /* 392 * bestConversion picks the best conversion factor for the atomic value. The 393 * best is the one in which log10(converted_value) is closest to 394 * bestDisplayOrder. Return: [converted_value, precision, unit]. 395 */ 396 static bestConversion (atoms: number, ui: UnitInfo, prefs?: Record<string, boolean>): [number, number, string] { 397 const unitFactors = resolveUnitConversions(ui, prefs) 398 const logDiffs: [string, number][] = [] 399 const entryDiff = (entry: [string, number]) => Math.abs(Math.log10(atoms / entry[1]) - bestDisplayOrder) 400 for (const entry of Object.entries(unitFactors)) logDiffs.push([entry[0], entryDiff(entry)]) 401 const best = logDiffs.reduce((best: [string, number], entry: [string, number]) => entry[1] < best[1] ? entry : best) 402 const unit = best[0] 403 const cFactor = unitFactors[unit] 404 const v = atoms / cFactor 405 return [v, Math.round(Math.log10(cFactor)), unit] 406 } 407 408 /* 409 * formatBestUnitsFullPrecision formats the value with the best choice of 410 * units, at full precision. 411 */ 412 static formatBestUnitsFullPrecision (atoms: number, ui: UnitInfo, prefs?: Record<string, boolean>): [string, string] { 413 const [v, prec, unit] = this.bestConversion(atoms, ui, prefs) 414 if (Number.isInteger(v)) return [intFormatter.format(v), unit] 415 return [fullPrecisionFormatter(prec).format(v), unit] 416 } 417 418 /* 419 * formatBestUnitsFourSigFigs formats the value with the best choice of 420 * units and rounded to four significant figures. 421 */ 422 static formatBestUnitsFourSigFigs (atoms: number, ui: UnitInfo, prefs?: Record<string, boolean>): [string, string] { 423 const [v, prec, unit] = this.bestConversion(atoms, ui, prefs) 424 return [Doc.formatFourSigFigs(v, prec), unit] 425 } 426 427 /* 428 * formatBestRateElement formats a rate using the best available units and 429 * updates the UI element. The ancestor should have descendents with data 430 * attributes [best-value, data-unit, data-unit-box, data-denom]. 431 */ 432 static formatBestRateElement (ancestor: PageElement, assetID: number, atoms: number, ui: UnitInfo, prefs?: Record<string, boolean>) { 433 Doc.formatBestValueElement(ancestor, assetID, atoms, ui, prefs) 434 Doc.setText(ancestor, '[data-denom]', ui.feeRateDenom) 435 } 436 437 /* 438 * formatBestRateElement formats a value using the best available units and 439 * updates the UI element. The ancestor should have descendents with data 440 * attributes [best-value, data-unit, data-unit-box]. 441 */ 442 static formatBestValueElement (ancestor: PageElement, assetID: number, atoms: number, ui: UnitInfo, prefs?: Record<string, boolean>) { 443 const [v, unit] = this.formatBestUnitsFourSigFigs(atoms, ui, prefs) 444 Doc.setText(ancestor, '[data-value]', v) 445 Doc.setText(ancestor, '[data-unit]', unit) 446 const span = Doc.safeSelector(ancestor, '[data-unit-box]') 447 span.dataset.atoms = String(atoms) 448 span.dataset.assetID = String(assetID) 449 } 450 451 static conventionalRateStep (rateStepEnc: number, baseUnitInfo: UnitInfo, quoteUnitInfo: UnitInfo) { 452 const [qFactor, bFactor] = [quoteUnitInfo.conventional.conversionFactor, baseUnitInfo.conventional.conversionFactor] 453 return rateStepEnc / RateEncodingFactor * (bFactor / qFactor) 454 } 455 456 /* 457 * logoPath creates a path to a png logo for the specified ticker symbol. If 458 * the symbol is not a supported asset, the generic letter logo will be 459 * requested instead. 460 */ 461 static logoPath (symbol: string): string { 462 if (BipSymbols.indexOf(symbol) === -1) symbol = symbol.substring(0, 1) 463 symbol = symbol.split('.')[0] // e.g. usdc.eth => usdc 464 return `/img/coins/${symbol}.png` 465 } 466 467 static bipSymbol (assetID: number): string { 468 return BipIDs[assetID] 469 } 470 471 static bipIDFromSymbol (symbol: string): number { 472 return BipSymbolIDs[symbol] 473 } 474 475 static bipCEXSymbol (assetID: number): string { 476 const bipSymbol = BipIDs[assetID] 477 if (!bipSymbol || bipSymbol === '') return '' 478 const parts = bipSymbol.split('.') 479 if (parts[0] === 'weth') return 'eth' 480 return parts[0] 481 } 482 483 static logoPathFromID (assetID: number): string { 484 return Doc.logoPath(BipIDs[assetID]) 485 } 486 487 /* 488 * symbolize creates a token-aware symbol element for the asset's symbol. For 489 * non-token assets, this is simply a <span>SYMBOL</span>. For tokens, it'll 490 * be <span><span>SYMBOL</span><sup>PARENT</sup></span>. 491 */ 492 static symbolize (asset: Symbolizer, useLogo?: boolean): PageElement { 493 const ticker = asset.unitInfo.conventional.unit 494 const symbolSpan = document.createElement('span') 495 symbolSpan.textContent = ticker.toUpperCase() 496 const parts = asset.symbol.split('.') 497 const isToken = parts.length === 2 498 if (!isToken) return symbolSpan 499 const parentSymbol = parts[1] 500 const span = document.createElement('span') 501 span.appendChild(symbolSpan) 502 if (useLogo) { 503 const parentLogo = document.createElement('img') 504 parentLogo.src = Doc.logoPath(parentSymbol) 505 parentLogo.classList.add('token-parent') 506 span.appendChild(parentLogo) 507 return span 508 } 509 const parentSup = document.createElement('sup') 510 parentSup.textContent = parentSymbol.toUpperCase() 511 parentSup.classList.add('token-parent') 512 span.appendChild(parentSup) 513 return span 514 } 515 516 /* 517 * shortSymbol removes the short format of a symbol, with any parent chain 518 * identifier removed 519 */ 520 static shortSymbol (symbol: string): string { 521 return symbol.split('.')[0].toUpperCase() 522 } 523 524 /* 525 * setText sets the textContent for all descendant elements that match the 526 * specified CSS selector. 527 */ 528 static setText (ancestor: PageElement, selector: string, textContent: string) { 529 for (const el of Doc.applySelector(ancestor, selector)) el.textContent = textContent 530 } 531 532 static setSrc (ancestor: PageElement, selector: string, textContent: string) { 533 for (const img of Doc.applySelector(ancestor, selector)) img.src = textContent 534 } 535 536 /* 537 * cleanTemplates removes the elements from the DOM and deletes the id 538 * attribute. 539 */ 540 static cleanTemplates (...tmpls: HTMLElement[]) { 541 tmpls.forEach(tmpl => { 542 tmpl.remove() 543 tmpl.removeAttribute('id') 544 }) 545 } 546 547 /* 548 * tmplElement is a helper function for grabbing sub-elements of the market list 549 * template. 550 */ 551 static tmplElement (ancestor: Document | HTMLElement, s: string): PageElement { 552 return ancestor.querySelector(`[data-tmpl="${s}"]`) || document.createElement('div') 553 } 554 555 /* 556 * parseTemplate returns an object of data-tmpl elements, keyed by their 557 * data-tmpl values. 558 */ 559 static parseTemplate (ancestor: HTMLElement): Record<string, PageElement> { 560 const d: Record<string, PageElement> = {} 561 for (const el of Doc.applySelector(ancestor, '[data-tmpl]')) d[el.dataset.tmpl || ''] = el 562 return d 563 } 564 565 /* 566 * timeSince returns a string representation of the duration since the 567 * specified unix timestamp (milliseconds). 568 */ 569 static timeSince (ms: number): string { 570 return Doc.formatDuration((new Date().getTime()) - ms) 571 } 572 573 /* 574 * hmsSince returns a time duration since the specified unix timestamp 575 * formatted as HH:MM:SS 576 */ 577 static hmsSince (secs: number) { 578 let r = (new Date().getTime() / 1000) - secs 579 const h = String(Math.floor(r / 3600)) 580 r = r % 3600 581 const m = String(Math.floor(r / 60)) 582 const s = String(Math.floor(r % 60)) 583 return `${h.padStart(2, '0')}:${m.padStart(2, '0')}:${s.padStart(2, '0')}` 584 } 585 586 /* formatDuration returns a string representation of the duration */ 587 static formatDuration (dur: number): string { 588 let seconds = Math.floor(dur) 589 let result = '' 590 let count = 0 591 const add = (n: number, s: string) => { 592 if (n > 0 || count > 0) count++ 593 if (n > 0) result += `${n} ${s} ` 594 return count >= 2 595 } 596 let y, mo, d, h, m, s 597 [y, seconds] = timeMod(seconds, aYear) 598 if (add(y, 'y')) { return result } 599 [mo, seconds] = timeMod(seconds, aMonth) 600 if (add(mo, 'mo')) { return result } 601 [d, seconds] = timeMod(seconds, aDay) 602 if (add(d, 'd')) { return result } 603 [h, seconds] = timeMod(seconds, anHour) 604 if (add(h, 'h')) { return result } 605 [m, seconds] = timeMod(seconds, aMinute) 606 if (add(m, 'm')) { return result } 607 [s, seconds] = timeMod(seconds, 1000) 608 add(s, 's') 609 return result || '0 s' 610 } 611 612 /* 613 * disableMouseWheel can be used to disable the mouse wheel for any 614 * input. It is very easy to unknowingly scroll up on a number input 615 * and then submit an unexpected value. This function prevents the 616 * scroll increment/decrement behavior for a wheel action on a 617 * number input. 618 */ 619 static disableMouseWheel (...inputFields: Element[]) { 620 for (const inputField of inputFields) { 621 Doc.bind(inputField, 'wheel', () => { /* pass */ }, { passive: true }) 622 } 623 } 624 625 // showFormError can be used to set and display error message on forms. 626 static showFormError (el: PageElement, msg: any) { 627 el.textContent = msg 628 Doc.show(el) 629 } 630 631 // showFiatValue displays the fiat equivalent for the provided amount. 632 static showFiatValue (display: PageElement, amount: number, rate: number, ui: UnitInfo): void { 633 if (rate) { 634 display.textContent = Doc.formatFiatConversion(amount, rate, ui) 635 Doc.show(display.parentElement as Element) 636 } else Doc.hide(display.parentElement as Element) 637 } 638 } 639 640 /* 641 * Animation is a handler for starting and stopping animations. 642 */ 643 export class Animation { 644 done: (() => void) | undefined 645 endAnimation: boolean 646 thread: Promise<void> 647 static Forever: number 648 649 constructor (duration: number, f: (progress: number) => void, easingAlgo?: string, done?: () => void) { 650 this.done = done 651 this.thread = this.run(duration, f, easingAlgo) 652 } 653 654 /* 655 * run runs the animation function, increasing progress from 0 to 1 in a 656 * manner dictated by easingAlgo. 657 */ 658 async run (duration: number, f: (progress: number) => void, easingAlgo?: string) { 659 duration = duration >= 0 ? duration : 1000 * 86400 * 365 * 10 // 10 years, in ms 660 const easer = easingAlgo ? Easing[easingAlgo] : Easing.linear 661 const start = new Date().getTime() 662 const end = (duration === Animation.Forever) ? Number.MAX_SAFE_INTEGER : start + duration 663 const range = end - start 664 const frameDuration = 1000 / FPS 665 let now = start 666 this.endAnimation = false 667 while (now < end) { 668 if (this.endAnimation) return this.runCompletionFunction() 669 f(easer((now - start) / range)) 670 await sleep(frameDuration) 671 now = new Date().getTime() 672 } 673 f(1) 674 this.runCompletionFunction() 675 } 676 677 /* wait returns a promise that will resolve when the animation completes. */ 678 async wait () { 679 await this.thread 680 } 681 682 /* stop schedules the animation to exit at its next frame. */ 683 stop () { 684 this.endAnimation = true 685 } 686 687 /* 688 * stopAndWait stops the animations and returns a promise that will resolve 689 * when the animation exits. 690 */ 691 async stopAndWait () { 692 this.stop() 693 await this.wait() 694 } 695 696 /* runCompletionFunction runs any registered callback function */ 697 runCompletionFunction () { 698 if (this.done) this.done() 699 } 700 } 701 Animation.Forever = -1 702 703 /* Easing algorithms for animations. */ 704 export const Easing: Record<string, (t: number) => number> = { 705 linear: t => t, 706 easeIn: t => t * t, 707 easeOut: t => t * (2 - t), 708 easeInHard: t => t * t * t, 709 easeOutHard: t => (--t) * t * t + 1, 710 easeOutElastic: t => { 711 const c4 = (2 * Math.PI) / 3 712 return t === 0 713 ? 0 714 : t === 1 715 ? 1 716 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1 717 } 718 } 719 720 /* WalletIcons are used for controlling wallets in various places. */ 721 export class WalletIcons { 722 icons: Record<string, HTMLElement> 723 status: Element 724 725 constructor (box: HTMLElement) { 726 const stateElement = (name: string) => box.querySelector(`[data-state=${name}]`) as HTMLElement 727 this.icons = {} 728 this.icons.sleeping = stateElement('sleeping') 729 this.icons.locked = stateElement('locked') 730 this.icons.unlocked = stateElement('unlocked') 731 this.icons.nowallet = stateElement('nowallet') 732 this.icons.syncing = stateElement('syncing') 733 this.icons.nopeers = stateElement('nopeers') 734 this.icons.disabled = stateElement('disabled') 735 this.status = stateElement('status') 736 } 737 738 /* sleeping sets the icons to indicate that the wallet is not connected. */ 739 sleeping () { 740 const i = this.icons 741 Doc.hide(i.locked, i.unlocked, i.nowallet, i.syncing, i.disabled) 742 Doc.show(i.sleeping) 743 if (this.status) this.status.textContent = intl.prep(intl.ID_OFF) 744 } 745 746 /* 747 * locked sets the icons to indicate that the wallet is connected, but locked. 748 */ 749 locked () { 750 const i = this.icons 751 Doc.hide(i.unlocked, i.nowallet, i.sleeping, i.disabled) 752 Doc.show(i.locked) 753 if (this.status) this.status.textContent = intl.prep(intl.ID_LOCKED) 754 } 755 756 /* 757 * unlocked sets the icons to indicate that the wallet is connected and 758 * unlocked. 759 */ 760 unlocked () { 761 const i = this.icons 762 Doc.hide(i.locked, i.nowallet, i.sleeping, i.disabled) 763 Doc.show(i.unlocked) 764 if (this.status) this.status.textContent = intl.prep(intl.ID_READY) 765 } 766 767 /* nowallet sets the icons to indicate that no wallet exists. */ 768 nowallet () { 769 const i = this.icons 770 Doc.hide(i.locked, i.unlocked, i.sleeping, i.syncing, i.disabled) 771 Doc.show(i.nowallet) 772 if (this.status) this.status.textContent = intl.prep(intl.ID_NO_WALLET) 773 } 774 775 /* set the icons to indicate that the wallet is disabled */ 776 disabled () { 777 const i = this.icons 778 Doc.hide(i.locked, i.unlocked, i.sleeping, i.syncing, i.nowallet, i.nopeers) 779 Doc.show(i.disabled) 780 i.disabled.dataset.tooltip = intl.prep(intl.ID_DISABLED_MSG) 781 } 782 783 setSyncing (wallet: WalletState | null) { 784 const syncIcon = this.icons.syncing 785 if (!wallet || !wallet.running || wallet.disabled) { 786 Doc.hide(syncIcon) 787 return 788 } 789 790 if (wallet.peerCount === 0) { 791 Doc.show(this.icons.nopeers) 792 Doc.hide(syncIcon) // potentially misleading with no peers 793 return 794 } 795 Doc.hide(this.icons.nopeers) 796 797 if (!wallet.synced) { 798 Doc.show(syncIcon) 799 syncIcon.dataset.tooltip = intl.prep(intl.ID_WALLET_SYNC_PROGRESS, { syncProgress: (wallet.syncProgress * 100).toFixed(1) }) 800 return 801 } 802 Doc.hide(syncIcon) 803 } 804 805 /* reads the core.Wallet state and sets the icon visibility. */ 806 readWallet (wallet: WalletState | null) { 807 this.setSyncing(wallet) 808 if (!wallet) return this.nowallet() 809 switch (true) { 810 case (wallet.disabled): 811 this.disabled() 812 break 813 case (!wallet.running): 814 this.sleeping() 815 break 816 case (!wallet.open): 817 this.locked() 818 break 819 case (wallet.open): 820 this.unlocked() 821 break 822 default: 823 console.error('wallet in unknown state', wallet) 824 } 825 } 826 } 827 828 /* 829 * AniToggle is a small toggle switch, defined in HTML with the element 830 * <div class="anitoggle"></div>. The animations are defined in the anitoggle 831 * CSS class. AniToggle triggers the callback on click events, but does not 832 * update toggle appearance, so the caller must call the setState method from 833 * the callback or elsewhere if the newState 834 * is accepted. 835 */ 836 export class AniToggle { 837 toggle: PageElement 838 toggling: boolean 839 840 constructor (toggle: PageElement, errorEl: PageElement, initialState: boolean, callback: (newState: boolean) => Promise<any>) { 841 this.toggle = toggle 842 if (toggle.children.length === 0) toggle.appendChild(document.createElement('div')) 843 844 Doc.bind(toggle, 'click', async (e: MouseEvent) => { 845 e.stopPropagation() 846 Doc.hide(errorEl) 847 const newState = !toggle.classList.contains('on') 848 this.toggling = true 849 try { 850 await callback(newState) 851 } catch (e) { 852 this.toggling = false 853 Doc.show(errorEl) 854 errorEl.textContent = intl.prep(intl.ID_API_ERROR, { msg: e.msg || String(e) }) 855 return 856 } 857 this.toggling = false 858 }) 859 this.setState(initialState) 860 } 861 862 setState (state: boolean) { 863 if (state) this.toggle.classList.add('on') 864 else this.toggle.classList.remove('on') 865 } 866 } 867 868 /* sleep can be used by async functions to pause for a specified period. */ 869 function sleep (ms: number) { 870 return new Promise(resolve => setTimeout(resolve, ms)) 871 } 872 873 const aYear = 31536000000 874 const aMonth = 2592000000 875 const aDay = 86400000 876 const anHour = 3600000 877 const aMinute = 60000 878 879 /* timeMod returns the quotient and remainder of t / dur. */ 880 function timeMod (t: number, dur: number) { 881 const n = Math.floor(t / dur) 882 return [n, t - n * dur] 883 } 884 885 function formatSigFigsWithFormatters (intFormatter: Intl.NumberFormat, sigFigFormatter: Intl.NumberFormat, n: number, maxDecimals?: number, locales?: string | string[]): string { 886 if (n >= 1000) return intFormatter.format(n) 887 const s = sigFigFormatter.format(n) 888 if (typeof maxDecimals !== 'number') return s 889 const fractional = sigFigFormatter.formatToParts(n).filter((part: Intl.NumberFormatPart) => part.type === 'fraction')[0]?.value ?? '' 890 if (fractional.length <= maxDecimals) return s 891 return fullPrecisionFormatter(maxDecimals, locales).format(n) 892 } 893 894 if (process.env.NODE_ENV === 'development') { 895 // Code will only appear in dev build. 896 // https://webpack.js.org/guides/production/ 897 window.testFormatFourSigFigs = () => { 898 const tests: [string, string, number | undefined, string][] = [ 899 ['en-US', '1.234567', undefined, '1.235'], // sigFigFormatter 900 ['en-US', '1.234567', 2, '1.23'], // decimalFormatter 901 ['en-US', '1234', undefined, '1,234.0'], // oneFractionalDigit 902 ['en-US', '12', undefined, '12.00'], // sigFigFormatter 903 ['fr-FR', '123.45678', undefined, '123,5'], // oneFractionalDigit 904 ['fr-FR', '1234.5', undefined, '1 234,5'], // U+202F for thousands separator 905 // For Arabic, https://www.saitak.com/number is useful, but seems to use 906 // slightly different unicode points and no thousands separator. I think 907 // the Arabic decimal separator is supposed to be more like a point, not 908 // a comma, but Google Chrome uses U+066B (Arabic Decimal Separator), 909 // which looks like a comma to me. ¯\_(ツ)_/¯ 910 ['ar-EG', '123.45678', undefined, '١٢٣٫٥'], 911 ['ar-EG', '1234', undefined, '١٬٢٣٤٫٠'], 912 ['ar-EG', '0.12345', 3, '٠٫١٢٣'] 913 ] 914 915 // Reproduce the NumberFormats with ONLY our desired language. 916 for (const [code, unformatted, maxDecimals, expected] of tests) { 917 const intFormatter = new Intl.NumberFormat(code, { // oneFractionalDigit 918 minimumFractionDigits: 1, 919 maximumFractionDigits: 1 920 }) 921 const sigFigFormatter = new Intl.NumberFormat(code, { 922 minimumSignificantDigits: 4, 923 maximumSignificantDigits: 4 924 }) 925 for (const k in decimalFormatters) delete decimalFormatters[k] // cleanup 926 for (const k in fullPrecisionFormatters) delete fullPrecisionFormatters[k] // cleanup 927 const s = formatSigFigsWithFormatters(intFormatter, sigFigFormatter, parseFloatDefault(unformatted), maxDecimals, code) 928 if (s !== expected) console.log(`TEST FAILED: f('${code}', ${unformatted}, ${maxDecimals}) => '${s}' != '${expected}'}`) 929 else console.log(`✔️ f('${code}', ${unformatted}, ${maxDecimals}) => ${s} ✔️`) 930 } 931 } 932 933 window.testFormatRateFullPrecision = () => { 934 const tests: [number, number, number, number, string][] = [ 935 // Two utxo assets with a conventional rate of 0.15. Conventional rate 936 // step is 100 / 1e8 = 1e-6, so there should be 6 decimal digits. 937 [1.5e7, 100, 1e8, 1e8, '0.150000'], 938 // USDC quote -> utxo base with a rate of $10 / 1 XYZ. USDC has an 939 // conversion factor of 1e6, so $10 encodes to 1e7, 1 XYZ encodes to 1e8, 940 // encoded rate is 1e7 / 1e8 * 1e8 = 1e7, bFactor / qFactor is 1e2. 941 // The conventional rate step is 200 / 1e8 * 1e2 = 2e-4, so using 942 // rateStepDigits, we should get 4 decimal digits. 943 [1e7, 200, 1e6, 1e8, '10.0000'], 944 // Set a rate of 1 atom USDC for 0.01 BTC. That atomic rate will be 1 / 945 // 1e6 = 1e-6. The encoded rate will be 1e-6 * 1e8 = 1e2. As long as our 946 // rate step divides evenly into 100, this should work. The conventional 947 // rate is 1e-6 / 1e-2 = 1e-4, so expect 4 decimal digits. 948 [1e2, 100, 1e6, 1e8, '0.0001'], 949 // DCR-ETH, expect 6 decimals. 950 [1.5e7, 1000, 1e9, 1e8, '0.015000'], 951 [1e6, 1000, 1e9, 1e8, '0.001000'], 952 [1e3, 1000, 1e9, 1e8, '0.000001'], 953 [100001000, 1000, 1e9, 1e8, '0.100001'], 954 [1000001000, 1000, 1e9, 1e8, '1.000001'], 955 // DCR-USDC, expect 3 decimals. 956 [1.5e7, 1000, 1e6, 1e8, '15.000'], 957 [1e6, 1000, 1e6, 1e8, '1.000'], 958 [1e3, 1000, 1e6, 1e8, '0.001'], 959 [101000, 1000, 1e6, 1e8, '0.101'], 960 [1001000, 1000, 1e6, 1e8, '1.001'], 961 // UTXO assets but with a rate step that's not a perfect power of 10. 962 // For a rate step of 500, a min rate would be e.g. rate step = 500. 963 // 5e2 / 1e8 = 5e-6 = 0.000005 964 [5e2, 500, 1e8, 1e8, '0.000005'] 965 ] 966 967 for (const [encRate, rateStep, qFactor, bFactor, expEncoding] of tests) { 968 for (const k in fullPrecisionFormatters) delete fullPrecisionFormatters[k] // cleanup 969 const bui = { conventional: { conversionFactor: bFactor } } as any as UnitInfo 970 const qui = { conventional: { conversionFactor: qFactor } } as any as UnitInfo 971 const enc = Doc.formatRateFullPrecision(encRate, bui, qui, rateStep) 972 if (enc !== expEncoding) console.log(`TEST FAILED: f(${encRate}, ${bFactor}, ${qFactor}, ${rateStep}) => ${enc} != ${expEncoding}`) 973 else console.log(`✔️ f(${encRate}, ${bFactor}, ${qFactor}, ${rateStep}) => ${enc} ✔️`) 974 } 975 } 976 } 977 978 export interface NumberInputOpts { 979 prec?: number 980 sigFigs?: boolean 981 changed?: (v: number) => void 982 min?: number 983 set?: (v: number, s: string) => void // called when setValue is called 984 } 985 986 export class NumberInput { 987 input: PageElement 988 prec: number 989 fmt: (v: number, prec: number) => [number, string] 990 changed: (v: number) => void 991 set?: (v: number, s: string) => void 992 min: number 993 994 constructor (input: PageElement, opts: NumberInputOpts) { 995 this.input = input 996 this.prec = opts.prec ?? 0 997 this.fmt = opts.sigFigs ? toFourSigFigs : toPrecision 998 this.changed = opts.changed ?? (() => { /* pass */ }) 999 this.set = opts.set 1000 this.min = opts.min ?? 0 1001 1002 Doc.bind(input, 'change', () => { this.inputChanged() }) 1003 } 1004 1005 inputChanged () { 1006 const { changed } = this 1007 if (changed) changed(this.value()) 1008 } 1009 1010 setValue (v: number) { 1011 this.input.value = String(v) 1012 v = this.value() 1013 if (this.set) this.set(v, this.input.value) 1014 } 1015 1016 value () { 1017 const { input, min, prec, fmt } = this 1018 const rawV = Math.max(parseFloatDefault(input.value, min ?? 0), min ?? 0) 1019 const [v, s] = fmt(rawV, prec ?? 0) 1020 input.value = s 1021 return v 1022 } 1023 } 1024 1025 export interface IncrementalInputOpts extends NumberInputOpts { 1026 inc?: number 1027 } 1028 1029 export class IncrementalInput extends NumberInput { 1030 inc: number 1031 opts: IncrementalInputOpts 1032 1033 constructor (box: PageElement, opts: IncrementalInputOpts) { 1034 super(Doc.safeSelector(box, 'input'), opts) 1035 this.opts = opts 1036 this.inc = opts.inc ?? 1 1037 1038 const up = Doc.safeSelector(box, '.ico-arrowup') 1039 const down = Doc.safeSelector(box, '.ico-arrowdown') 1040 1041 Doc.bind(up, 'click', () => { this.increment(1) }) 1042 Doc.bind(down, 'click', () => { this.increment(-1) }) 1043 } 1044 1045 setIncrementAndMinimum (inc: number, min: number) { 1046 this.inc = inc 1047 this.min = min 1048 } 1049 1050 increment (sign: number) { 1051 const { inc, min, input } = this 1052 input.value = String(Math.max(this.value() + sign * inc, min)) 1053 this.inputChanged() 1054 } 1055 } 1056 1057 export class MiniSlider { 1058 track: PageElement 1059 ball: PageElement 1060 r: number 1061 changed: (r: number) => void 1062 1063 constructor (box: PageElement, changed: (r: number) => void) { 1064 this.changed = changed 1065 this.r = 0 1066 1067 const color = document.createElement('div') 1068 color.dataset.tmpl = 'color' 1069 box.appendChild(color) 1070 const track = this.track = document.createElement('div') 1071 track.dataset.tmpl = 'track' 1072 color.appendChild(track) 1073 const ball = this.ball = document.createElement('div') 1074 ball.dataset.tmpl = 'ball' 1075 track.appendChild(ball) 1076 1077 Doc.bind(box, 'mousedown', (e: MouseEvent) => { 1078 if (e.button !== 0) return 1079 e.preventDefault() 1080 e.stopPropagation() 1081 const startX = e.pageX 1082 const w = track.clientWidth 1083 const startLeft = this.r * w 1084 const left = (ee: MouseEvent) => Math.max(Math.min(startLeft + (ee.pageX - startX), w), 0) 1085 const trackMouse = (ee: MouseEvent) => { 1086 ee.preventDefault() 1087 const l = left(ee) 1088 this.r = l / w 1089 ball.style.left = `${this.r * 100}%` 1090 this.changed(this.r) 1091 } 1092 const mouseUp = (ee: MouseEvent) => { 1093 trackMouse(ee) 1094 Doc.unbind(document, 'mousemove', trackMouse) 1095 Doc.unbind(document, 'mouseup', mouseUp) 1096 } 1097 Doc.bind(document, 'mousemove', trackMouse) 1098 Doc.bind(document, 'mouseup', mouseUp) 1099 }) 1100 1101 Doc.bind(box, 'click', (e: MouseEvent) => { 1102 if (e.button !== 0) return 1103 const x = e.pageX 1104 const m = Doc.layoutMetrics(track) 1105 this.r = clamp((x - m.bodyLeft) / m.width, 0, 1) 1106 ball.style.left = `${this.r * m.width}px` 1107 this.changed(this.r) 1108 }) 1109 } 1110 1111 setValue (r: number) { 1112 this.r = clamp(r, 0, 1) 1113 this.ball.style.left = `${this.r * 100}%` 1114 } 1115 } 1116 1117 export function toPrecision (v: number, prec: number): [number, string] { 1118 const ord = Math.pow(10, prec ?? 0) 1119 v = Math.round(v * ord) / ord 1120 let s = v.toFixed(prec) 1121 if (prec > 0) { 1122 while (s.endsWith('0')) s = s.substring(0, s.length - 1) 1123 if (s.endsWith('.')) s = s.substring(0, s.length - 1) 1124 } 1125 return [v, s] 1126 } 1127 1128 export function toFourSigFigs (v: number, maxPrec: number): [number, string] { 1129 const ord = Math.floor(Math.log10(Math.abs(v))) 1130 if (ord >= 3) return [Math.round(v), v.toFixed(0)] 1131 const prec = Math.min(4 - ord, maxPrec) 1132 return toPrecision(v, prec) 1133 } 1134 1135 export function parseFloatDefault (inputValue: string | undefined, defaultValue?: number) { 1136 const v = parseFloat((inputValue ?? '').replace(/,/g, '')) 1137 if (!isNaN(v)) return v 1138 return defaultValue ?? 0 1139 } 1140 1141 /* clamp returns v if min <= v <= max, else min or max. */ 1142 export function clamp (v: number, min: number, max: number): number { 1143 if (v < min) return min 1144 if (v > max) return max 1145 return v 1146 } 1147 1148 export async function setupCopyBtn (txt: string, textEl: PageElement, btnEl: PageElement, color: string) { 1149 try { 1150 await navigator.clipboard.writeText(txt) 1151 } catch (err) { 1152 console.error('Unable to copy: ', err) 1153 } 1154 const textOriginalColor = textEl.style.color 1155 const btnOriginalColor = btnEl.style.color 1156 textEl.style.color = color 1157 btnEl.style.color = color 1158 setTimeout(() => { 1159 textEl.style.color = textOriginalColor 1160 btnEl.style.color = btnOriginalColor 1161 }, 350) 1162 }