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  }