decred.org/dcrdex@v1.0.5/client/webserver/site/src/js/charts.ts (about)

     1  import Doc, { Animation, clamp } from './doc'
     2  import { RateEncodingFactor } from './orderutil'
     3  import OrderBook from './orderbook'
     4  import State from './state'
     5  import { UnitInfo, Market, Candle, CandlesPayload, app } from './registry'
     6  
     7  const bind = Doc.bind
     8  const PIPI = 2 * Math.PI
     9  const plusChar = String.fromCharCode(59914)
    10  const minusChar = String.fromCharCode(59915)
    11  
    12  interface Point {
    13    x: number
    14    y: number
    15  }
    16  
    17  interface MinMax {
    18    min: number
    19    max: number
    20  }
    21  
    22  interface Label {
    23    val: number
    24    txt: string
    25  }
    26  
    27  interface LabelSet {
    28    widest?: number
    29    lbls: Label[]
    30  }
    31  
    32  export interface Translator {
    33      x: (x: number) => number
    34      y: (y: number) => number
    35      unx: (x: number) => number
    36      uny: (y: number) => number
    37      w: (w: number) => number
    38      h: (h: number) => number
    39  }
    40  
    41  export interface MouseReport {
    42    rate: number
    43    depth: number
    44    dotColor: string
    45    hoverMarkers: number[]
    46  }
    47  
    48  export interface VolumeReport {
    49    buyBase: number
    50    buyQuote: number
    51    sellBase: number
    52    sellQuote: number
    53  }
    54  
    55  export interface DepthReporters {
    56    mouse: (r: MouseReport | null) => void
    57    click: (x: number) => void
    58    volume: (r: VolumeReport) => void
    59    zoom: (z: number) => void
    60  }
    61  
    62  export interface CandleReporters {
    63    mouse: (r: Candle | null) => void
    64  }
    65  
    66  export interface ChartReporters {
    67    resize: () => void,
    68    click: (e: MouseEvent) => void,
    69    zoom: (bigger: boolean) => void
    70  }
    71  
    72  export interface DepthLine {
    73    rate: number
    74    color: string
    75  }
    76  
    77  export interface DepthMarker {
    78    rate: number
    79    active: boolean
    80  }
    81  
    82  interface DepthMark extends DepthMarker {
    83    qty: number
    84    sell: boolean
    85  }
    86  
    87  interface Theme {
    88    body: string
    89    axisLabel: string
    90    gridBorder: string
    91    gridLines: string
    92    gapLine: string
    93    value: string
    94    zoom: string
    95    zoomHover: string
    96    sellLine: string
    97    buyLine: string
    98    sellFill: string
    99    buyFill: string
   100    crosshairs: string
   101    legendFill: string
   102    legendText: string
   103  }
   104  
   105  const darkTheme: Theme = {
   106    body: '#0b2031',
   107    axisLabel: '#b1b1b1',
   108    gridBorder: '#383f4b',
   109    gridLines: '#383f4b',
   110    gapLine: '#6b6b6b',
   111    value: '#9a9a9a',
   112    zoom: '#5b5b5b',
   113    zoomHover: '#aaa',
   114    sellLine: '#ae3333',
   115    buyLine: '#05a35a',
   116    sellFill: '#591a1a',
   117    buyFill: '#02572f',
   118    crosshairs: '#888',
   119    legendFill: 'black',
   120    legendText: '#d5d5d5'
   121  }
   122  
   123  const lightTheme: Theme = {
   124    body: '#f4f4f4',
   125    axisLabel: '#1b1b1b',
   126    gridBorder: '#ddd',
   127    gridLines: '#ddd',
   128    gapLine: '#595959',
   129    value: '#4d4d4d',
   130    zoom: '#777',
   131    zoomHover: '#333',
   132    sellLine: '#99302b',
   133    buyLine: '#207a46',
   134    sellFill: '#bd5959',
   135    buyFill: '#4cad75',
   136    crosshairs: '#595959',
   137    legendFill: '#e6e6e6',
   138    legendText: '#1b1b1b'
   139  }
   140  
   141  // Chart is the base class for charts.
   142  export class Chart {
   143    parent: HTMLElement
   144    report: ChartReporters
   145    theme: Theme
   146    canvas: HTMLCanvasElement
   147    visible: boolean
   148    renderScheduled: boolean
   149    ctx: CanvasRenderingContext2D
   150    mousePos: Point | null
   151    rect: DOMRect
   152    wheelLimiter: number | null
   153    boundResizer: () => void
   154    plotRegion: Region
   155    xRegion: Region
   156    yRegion: Region
   157    dataExtents: Extents
   158    unattachers: (() => void)[]
   159  
   160    constructor (parent: HTMLElement, reporters: ChartReporters) {
   161      this.parent = parent
   162      this.report = reporters
   163      this.theme = State.isDark() ? darkTheme : lightTheme
   164      this.canvas = document.createElement('canvas')
   165      this.visible = true
   166      parent.appendChild(this.canvas)
   167      const ctx = this.canvas.getContext('2d')
   168      if (!ctx) {
   169        console.error('error getting canvas context')
   170        return
   171      }
   172      this.ctx = ctx
   173      this.ctx.textAlign = 'center'
   174      this.ctx.textBaseline = 'middle'
   175      // Mouse handling
   176      this.mousePos = null
   177      bind(this.canvas, 'mousemove', (e: MouseEvent) => {
   178        // this.rect will be set in resize().
   179        if (!this.rect) return
   180        this.mousePos = {
   181          x: e.clientX - this.rect.left,
   182          y: e.clientY - this.rect.y
   183        }
   184        this.draw()
   185      })
   186      bind(this.canvas, 'mouseleave', () => {
   187        this.mousePos = null
   188        this.draw()
   189      })
   190  
   191      // Bind resize.
   192      const resizeObserver = new ResizeObserver(() => this.resize())
   193      resizeObserver.observe(this.parent)
   194  
   195      // Scrolling by wheel is smoother when the rate is slightly limited.
   196      this.wheelLimiter = null
   197      bind(this.canvas, 'wheel', (e: WheelEvent) => { this.wheel(e) }, { passive: true })
   198      bind(this.canvas, 'click', (e: MouseEvent) => { this.click(e) })
   199      const setVis = () => {
   200        this.visible = document.visibilityState !== 'hidden'
   201        if (this.visible && this.renderScheduled) {
   202          this.renderScheduled = false
   203          this.draw()
   204        }
   205      }
   206      bind(document, 'visibilitychange', setVis)
   207      this.unattachers = [() => { Doc.unbind(document, 'visibilitychange', setVis) }]
   208    }
   209  
   210    wheeled () {
   211      this.wheelLimiter = window.setTimeout(() => { this.wheelLimiter = null }, 100)
   212    }
   213  
   214    /* clear the canvas. */
   215    clear () {
   216      this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
   217    }
   218  
   219    /* draw calls the child class's render method. */
   220    draw () {
   221      this.render()
   222    }
   223  
   224    /* click is the handler for a click event on the canvas. */
   225    click (e: MouseEvent) {
   226      this.report.click(e)
   227    }
   228  
   229    /* wheel is a mousewheel event handler. */
   230    wheel (e: WheelEvent) {
   231      this.zoom(e.deltaY < 0)
   232    }
   233  
   234    /*
   235     * resize updates the chart size. The parentHeight is an argument to support
   236     * updating the height programmatically after the caller sets a style.height
   237     * but before the clientHeight has been updated.
   238     */
   239    resize () {
   240      this.canvas.width = this.parent.clientWidth
   241      this.canvas.height = this.parent.clientHeight
   242      const xLblHeight = 30
   243      const yGuess = 40 // y label width guess. Will be adjusted when drawn.
   244      const plotExtents = new Extents(0, this.canvas.width, 0, this.canvas.height - xLblHeight)
   245      const xLblExtents = new Extents(0, this.canvas.width, this.canvas.height - xLblHeight, this.canvas.height)
   246      const yLblExtents = new Extents(0, yGuess, 0, this.canvas.height - xLblHeight)
   247      this.plotRegion = new Region(this.ctx, plotExtents)
   248      this.xRegion = new Region(this.ctx, xLblExtents)
   249      this.yRegion = new Region(this.ctx, yLblExtents)
   250      // After changing the visibility, this.canvas.getBoundingClientRect will
   251      // return nonsense until a render.
   252      window.requestAnimationFrame(() => {
   253        this.rect = this.canvas.getBoundingClientRect()
   254        this.report.resize()
   255      })
   256    }
   257  
   258    /* zoom is called when the user scrolls the mouse wheel on the canvas. */
   259    zoom (bigger: boolean) {
   260      if (this.wheelLimiter) return
   261      this.report.zoom(bigger)
   262    }
   263  
   264    /* The market handler will call unattach when the markets page is unloaded. */
   265    unattach () {
   266      for (const u of this.unattachers) u()
   267      this.unattachers = []
   268    }
   269  
   270    /* render must be implemented by the child class. */
   271    render () {
   272      console.error('child class must override render method')
   273    }
   274  
   275    /* applyLabelStyle applies the style used for axis tick labels. */
   276    applyLabelStyle (fontSize?: number) {
   277      this.ctx.textAlign = 'center'
   278      this.ctx.textBaseline = 'middle'
   279      this.ctx.font = `${fontSize ?? '14'}px 'sans', sans-serif`
   280      this.ctx.fillStyle = this.theme.axisLabel
   281    }
   282  
   283    /* plotXLabels applies the provided labels to the x axis and draws the grid. */
   284    plotXLabels (labels: LabelSet, minX: number, maxX: number, unitLines: string[]) {
   285      const extents = new Extents(minX, maxX, 0, 1)
   286      this.xRegion.plot(extents, (ctx: CanvasRenderingContext2D, tools: Translator) => {
   287        this.applyLabelStyle()
   288        const centerX = (maxX + minX) / 2
   289        let lastX = minX
   290        let unitCenter = centerX
   291        const [leftEdge, rightEdge] = [tools.x(minX), tools.x(maxX)]
   292        const centerY = tools.y(0.5)
   293        labels.lbls.forEach(lbl => {
   294          const m = ctx.measureText(lbl.txt)
   295          const x = tools.x(lbl.val)
   296          if (x - m.width / 2 < leftEdge || x + m.width / 2 > rightEdge) return
   297          ctx.fillText(lbl.txt, x, centerY)
   298          if (centerX >= lastX && centerX < lbl.val) {
   299            unitCenter = (lastX + lbl.val) / 2
   300          }
   301          lastX = lbl.val
   302        })
   303        ctx.font = '11px \'sans\', sans-serif'
   304        if (unitLines.length === 2) {
   305          ctx.fillText(unitLines[0], tools.x(unitCenter), tools.y(0.63))
   306          ctx.fillText(unitLines[1], tools.x(unitCenter), tools.y(0.23))
   307        } else if (unitLines.length === 1) {
   308          ctx.fillText(unitLines[0], tools.x(unitCenter), centerY)
   309        }
   310      }, true)
   311    }
   312  
   313    plotXGrid (labels: LabelSet, minX: number, maxX: number) {
   314      const extents = new Extents(minX, maxX, 0, 1)
   315      this.plotRegion.plot(extents, (ctx: CanvasRenderingContext2D, tools: Translator) => {
   316        ctx.lineWidth = 1
   317        ctx.strokeStyle = this.theme.gridLines
   318        labels.lbls.forEach(lbl => {
   319          line(ctx, tools.x(lbl.val), tools.y(0), tools.x(lbl.val), tools.y(1))
   320        })
   321      }, true)
   322    }
   323  
   324    /*
   325     * plotYLabels applies the y labels based on the provided plot region, and
   326     * draws the grid.
   327     */
   328    plotYLabels (labels: LabelSet, minY: number, maxY: number, unit: string) {
   329      const extents = new Extents(0, 1, minY, maxY)
   330  
   331      const fillRect = (ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) => {
   332        ctx.save()
   333        ctx.fillStyle = this.theme.body
   334        ctx.beginPath()
   335        if (ctx.roundRect) ctx.roundRect(x, y, w, h, r) // Safari < 16 doesn't support
   336        else ctx.rect(x, y, w, h)
   337        ctx.fill()
   338        ctx.restore()
   339      }
   340  
   341      this.yRegion.plot(extents, (ctx: CanvasRenderingContext2D, tools: Translator) => {
   342        this.applyLabelStyle()
   343        this.ctx.textAlign = 'left'
   344        const centerY = maxY / 2
   345        let lastY = 0
   346        let unitCenter = centerY
   347        const x = tools.x(0)
   348        const [xPad, yPad] = [3, 3]
   349        labels.lbls.forEach(lbl => {
   350          const y = tools.y(lbl.val)
   351          if (y < tools.y(maxY) + yPad + 7 || y > tools.y(minY) - yPad - 7) return
   352          const m = ctx.measureText(lbl.txt)
   353          fillRect(ctx, x, y - 7 - yPad, m.width + xPad * 2, 14 + yPad * 3, 3)
   354          ctx.fillText(lbl.txt, x + xPad, y + 2)
   355          if (centerY >= lastY && centerY < lbl.val) {
   356            unitCenter = (lastY + lbl.val) / 2
   357          }
   358          lastY = lbl.val
   359        })
   360        const m = ctx.measureText(unit)
   361        const y = tools.y(unitCenter)
   362        fillRect(ctx, x, y - yPad - 7, m.width + xPad * 2, 14 + yPad * 2, 3)
   363        ctx.fillText(unit, x + xPad, tools.y(unitCenter))
   364      }, true)
   365    }
   366  
   367    plotYGrid (region: Region, labels: LabelSet, minY: number, maxY: number) {
   368      const extents = new Extents(0, 1, minY, maxY)
   369      region.plot(extents, (ctx: CanvasRenderingContext2D, tools: Translator) => {
   370        ctx.lineWidth = 1
   371        ctx.strokeStyle = this.theme.gridLines
   372        labels.lbls.forEach(lbl => {
   373          line(ctx, tools.x(0), tools.y(lbl.val), tools.x(1), tools.y(lbl.val))
   374        })
   375      }, true)
   376    }
   377  
   378    /*
   379     * doYLabels generates and applies the y-axis labels, based upon the
   380     * provided plot region.
   381     */
   382    makeYLabels (region: Region, step: number, unit: string, valFmt?: (v: number) => string): LabelSet {
   383      this.applyLabelStyle()
   384      const yLabels = makeLabels(this.ctx, region.height(), this.dataExtents.y.min,
   385        this.dataExtents.y.max, 50, step, unit, valFmt)
   386  
   387      // Reassign the width of the y-label column to accommodate the widest text.
   388      const yAxisWidth = (yLabels.widest || 0) + 20 /* x padding */
   389      this.yRegion.extents.x.max = yAxisWidth
   390      this.yRegion.extents.y.max = region.extents.y.max
   391  
   392      return yLabels
   393    }
   394  
   395    line (x0: number, y0: number, x1: number, y1: number, skipStroke?: boolean) {
   396      line(this.ctx, x0, y0, x1, y1, skipStroke)
   397    }
   398  
   399    /* dot draws a circle with the provided context. */
   400    dot (x: number, y: number, color: string, radius: number) {
   401      dot(this.ctx, x, y, color, radius)
   402    }
   403  }
   404  
   405  /* DepthChart is a javascript Canvas-based depth chart renderer. */
   406  export class DepthChart extends Chart {
   407    reporters: DepthReporters
   408    book: OrderBook
   409    zoomLevel: number
   410    lotSize: number
   411    conventionalRateStep: number
   412    lines: DepthLine[]
   413    markers: Record<string, DepthMarker[]>
   414    zoomInBttn: Region
   415    zoomOutBttn: Region
   416    baseUnit: string
   417    quoteUnit: string
   418  
   419    constructor (parent: HTMLElement, reporters: DepthReporters, zoom: number) {
   420      super(parent, {
   421        resize: () => this.resized(),
   422        click: (e: MouseEvent) => this.clicked(e),
   423        zoom: (bigger: boolean) => this.zoomed(bigger)
   424      })
   425      this.reporters = reporters
   426      this.zoomLevel = zoom
   427      this.lines = []
   428      this.markers = {
   429        buys: [],
   430        sells: []
   431      }
   432      this.setZoomBttns() // can't wait for requestAnimationFrame -> resized
   433      this.resize()
   434    }
   435  
   436    // setZoomBttns creates new regions for zoom in and zoom out buttons. It is
   437    // used in initiation of the buttons and resizing.
   438    setZoomBttns () {
   439      this.zoomInBttn = new Region(this.ctx, new Extents(0, 0, 0, 0))
   440      this.zoomOutBttn = new Region(this.ctx, new Extents(0, 0, 0, 0))
   441    }
   442  
   443    /* resized is called when the window or parent element are resized. */
   444    resized () {
   445      // The button region extents are set during drawing.
   446      this.setZoomBttns()
   447      if (this.book) this.draw()
   448    }
   449  
   450    /* zoomed zooms the current view in or out. bigger=true is zoom in. */
   451    zoomed (bigger: boolean) {
   452      if (!this.zoomLevel) return
   453      if (!this.book.buys || !this.book.sells) return
   454      this.wheeled()
   455      // Zoom in to 66%, but out to 150% = 1 / (2/3) so that the same zoom levels
   456      // are hit when reversing direction.
   457      this.zoomLevel *= bigger ? 2 / 3 : 3 / 2
   458      this.zoomLevel = clamp(this.zoomLevel, 0.005, 2)
   459      this.draw()
   460      this.reporters.zoom(this.zoomLevel)
   461    }
   462  
   463    /* clicked is the canvas 'click' event handler. */
   464    clicked (e: MouseEvent) {
   465      if (!this.dataExtents) return
   466      const x = e.clientX - this.rect.left
   467      const y = e.clientY - this.rect.y
   468      if (this.zoomInBttn.contains(x, y)) { this.zoom(true); return }
   469      if (this.zoomOutBttn.contains(x, y)) { this.zoom(false); return }
   470      const translator = this.plotRegion.translator(this.dataExtents)
   471      this.reporters.click(translator.unx(x))
   472    }
   473  
   474    // set sets the current data set and draws.
   475    set (book: OrderBook, lotSize: number, rateStepEnc: number, baseUnitInfo: UnitInfo, quoteUnitInfo: UnitInfo) {
   476      this.book = book
   477      this.lotSize = lotSize / baseUnitInfo.conventional.conversionFactor
   478      this.conventionalRateStep = Doc.conventionalRateStep(rateStepEnc, baseUnitInfo, quoteUnitInfo)
   479      this.baseUnit = baseUnitInfo.conventional.unit
   480      this.quoteUnit = quoteUnitInfo.conventional.unit
   481      if (!this.zoomLevel) {
   482        const [midGap, gapWidth] = this.gap()
   483        // Default to 5% zoom, but with a minimum of 5 * midGap, but still observing
   484        // the hard cap of 200%.
   485        const minZoom = Math.max(gapWidth / midGap * 5, 0.05)
   486        this.zoomLevel = Math.min(minZoom, 2)
   487      }
   488      this.draw()
   489    }
   490  
   491    /*
   492     * render draws the chart.
   493     * 1. Calculate the data extents and translate the order book data to a
   494     *    cumulative form.
   495     * 2. Draw axis ticks and grid, mid-gap line and value, zoom buttons, mouse
   496     *    position indicator...
   497     * 4. Tick labels.
   498     * 5. Data.
   499     * 6. Epoch line legend.
   500     * 7. Hover legend.
   501     */
   502    render () {
   503      // if connection fails it is not possible to get book.
   504      if (!this.book || !this.visible || this.canvas.width === 0) {
   505        this.renderScheduled = true
   506        return
   507      }
   508  
   509      this.clear()
   510      // if (!this.book || this.book.empty()) return
   511      const ctx = this.ctx
   512      const mousePos = this.mousePos
   513      const buys = this.book.buys
   514      const sells = this.book.sells
   515  
   516      const [midGap, gapWidth] = this.gap()
   517  
   518      const halfWindow = this.zoomLevel * midGap / 2
   519      const high = midGap + halfWindow
   520      const low = midGap - halfWindow
   521  
   522      // Get a sorted copy of the markers list.
   523      const buyMarkers = [...this.markers.buys]
   524      const sellMarkers = [...this.markers.sells]
   525      buyMarkers.sort((a, b) => b.rate - a.rate)
   526      sellMarkers.sort((a, b) => a.rate - b.rate)
   527      const markers: DepthMark[] = []
   528  
   529      const buyDepth: [number, number][] = []
   530      const buyEpoch: [number, number][] = []
   531      const sellDepth: [number, number][] = []
   532      const sellEpoch: [number, number][] = []
   533      const volumeReport = {
   534        buyBase: 0,
   535        buyQuote: 0,
   536        sellBase: 0,
   537        sellQuote: 0
   538      }
   539      let sum = 0
   540      // The epoch line is above the non-epoch region, so the epochSum y value
   541      // must account for non-epoch orders too.
   542      let epochSum = 0
   543  
   544      for (let i = 0; i < buys.length; i++) {
   545        const ord = buys[i]
   546        epochSum += ord.qty
   547        if (ord.rate >= low) buyEpoch.push([ord.rate, epochSum])
   548        if (ord.epoch) continue
   549        sum += ord.qty
   550        buyDepth.push([ord.rate, sum])
   551        volumeReport.buyBase += ord.qty
   552        volumeReport.buyQuote += ord.qty * ord.rate
   553        while (buyMarkers.length && floatCompare(buyMarkers[0].rate, ord.rate)) {
   554          const mark = buyMarkers.shift()
   555          if (!mark) continue
   556          markers.push({
   557            rate: mark.rate,
   558            qty: ord.epoch ? epochSum : sum,
   559            sell: ord.sell,
   560            active: mark.active
   561          })
   562        }
   563      }
   564      const buySum = buyDepth.length ? last(buyDepth)[1] : 0
   565      buyDepth.push([low, buySum])
   566      const epochBuySum = buyEpoch.length ? last(buyEpoch)[1] : 0
   567      buyEpoch.push([low, epochBuySum])
   568  
   569      epochSum = sum = 0
   570      for (let i = 0; i < sells.length; i++) {
   571        const ord = sells[i]
   572        epochSum += ord.qty
   573        if (ord.rate <= high) sellEpoch.push([ord.rate, epochSum])
   574        if (ord.epoch) continue
   575        sum += ord.qty
   576        sellDepth.push([ord.rate, sum])
   577        volumeReport.sellBase += ord.qty
   578        volumeReport.sellQuote += ord.qty * ord.rate
   579        while (sellMarkers.length && floatCompare(sellMarkers[0].rate, ord.rate)) {
   580          const mark = sellMarkers.shift()
   581          if (!mark) continue
   582          markers.push({
   583            rate: mark.rate,
   584            qty: ord.epoch ? epochSum : sum,
   585            sell: ord.sell,
   586            active: mark.active
   587          })
   588        }
   589      }
   590      // Add a data point going to the left so that the data doesn't end with a
   591      // vertical line.
   592      const sellSum = sellDepth.length ? last(sellDepth)[1] : 0
   593      sellDepth.push([high, sellSum])
   594      const epochSellSum = sellEpoch.length ? last(sellEpoch)[1] : 0
   595      sellEpoch.push([high, epochSellSum])
   596  
   597      // Add ~30px padding to the top of the chart.
   598      const h = this.xRegion.extents.y.min
   599      const growthFactor = (h + 40) / h
   600      const maxY = (epochSellSum && epochBuySum ? Math.max(epochBuySum, epochSellSum) : epochSellSum || epochBuySum || 1) * growthFactor
   601  
   602      const dataExtents = new Extents(low, high, 0, maxY)
   603      this.dataExtents = dataExtents
   604  
   605      // A function to be run at the end if there is legend data to display.
   606      let mouseData: MouseReport | null = null
   607  
   608      // Draw the grid.
   609      const xLabels = makeLabels(ctx, this.plotRegion.width(), dataExtents.x.min, dataExtents.x.max, 100, this.conventionalRateStep, '')
   610      this.plotXGrid(xLabels, low, high)
   611      const yLabels = this.makeYLabels(this.plotRegion, this.lotSize, this.baseUnit)
   612      this.plotYGrid(this.plotRegion, yLabels, this.dataExtents.y.min, this.dataExtents.y.max)
   613  
   614      this.plotRegion.plot(dataExtents, (ctx, tools) => {
   615        ctx.lineWidth = 1
   616        // first, a square around the plot area.
   617        ctx.strokeStyle = this.theme.gridBorder
   618        // draw a line to indicate mid-gap
   619        ctx.lineWidth = 2.5
   620        ctx.strokeStyle = this.theme.gapLine
   621        line(ctx, tools.x(midGap), tools.y(0), tools.x(midGap), tools.y(0.3 * dataExtents.y.max))
   622  
   623        ctx.font = '30px \'demi-sans\', sans-serif'
   624        ctx.textAlign = 'center'
   625        ctx.textBaseline = 'middle'
   626        ctx.fillStyle = this.theme.value
   627        const y = 0.5 * dataExtents.y.max
   628        ctx.fillText(Doc.formatFourSigFigs(midGap), tools.x(midGap), tools.y(y))
   629        ctx.font = '12px \'sans\', sans-serif'
   630        // ctx.fillText('mid-market price', tools.x(midGap), tools.y(y) + 24)
   631        ctx.fillText(`${(gapWidth / midGap * 100).toFixed(2)}% spread`,
   632          tools.x(midGap), tools.y(y) + 24)
   633  
   634        // Draw zoom buttons.
   635        ctx.textAlign = 'center'
   636        ctx.textBaseline = 'middle'
   637        const topCenterX = this.plotRegion.extents.midX
   638        const topCenterY = tools.y(maxY * 0.9)
   639        const zoomPct = dataExtents.xRange / midGap * 100
   640        const zoomText = `${zoomPct.toFixed(1)}%`
   641        const w = ctx.measureText(zoomText).width
   642        ctx.font = '13px \'sans\', sans-serif'
   643        ctx.fillText(zoomText, topCenterX, topCenterY + 1)
   644        // define the region for the zoom button
   645        const bttnSize = 20
   646        const xPad = 10
   647        let bttnLeft = topCenterX - w / 2 - xPad - bttnSize
   648        const bttnTop = topCenterY - bttnSize / 2
   649        this.zoomOutBttn.setExtents(
   650          bttnLeft,
   651          bttnLeft + bttnSize,
   652          bttnTop,
   653          bttnTop + bttnSize
   654        )
   655        let hover = mousePos && this.zoomOutBttn.contains(mousePos.x, mousePos.y)
   656        this.zoomOutBttn.plot(new Extents(0, 1, 0, 1), ctx => {
   657          ctx.font = '12px \'icomoon\''
   658          ctx.fillStyle = this.theme.zoom
   659          if (hover) {
   660            ctx.fillStyle = this.theme.zoomHover
   661            ctx.font = '13px \'icomoon\''
   662          }
   663          ctx.fillText(minusChar, this.zoomOutBttn.extents.midX, this.zoomOutBttn.extents.midY)
   664        })
   665        bttnLeft = topCenterX + w / 2 + xPad
   666        this.zoomInBttn.setExtents(
   667          bttnLeft,
   668          bttnLeft + bttnSize,
   669          bttnTop,
   670          bttnTop + bttnSize
   671        )
   672        hover = mousePos && this.zoomInBttn.contains(mousePos.x, mousePos.y)
   673        this.zoomInBttn.plot(new Extents(0, 1, 0, 1), ctx => {
   674          ctx.font = '12px \'icomoon\''
   675          ctx.fillStyle = this.theme.zoom
   676          if (hover) {
   677            ctx.fillStyle = this.theme.zoomHover
   678            ctx.font = '14px \'icomoon\''
   679          }
   680          ctx.fillText(plusChar, this.zoomInBttn.extents.midX, this.zoomInBttn.extents.midY)
   681        })
   682  
   683        // Draw a dotted vertical line where the mouse is, and a dot at the level
   684        // of the depth line.
   685        const drawLine = (x: number, color: string) => {
   686          if (x > high || x < low) return
   687          ctx.save()
   688          ctx.setLineDash([3, 5])
   689          ctx.lineWidth = 1.5
   690          ctx.strokeStyle = color
   691          line(ctx, tools.x(x), tools.y(0), tools.x(x), tools.y(maxY))
   692          ctx.restore()
   693        }
   694  
   695        // for (const line of this.lines || []) {
   696        //   drawLine(line.rate, line.color)
   697        // }
   698  
   699        const tolerance = (high - low) * 0.005
   700        const hoverMarkers = []
   701        for (const marker of markers || []) {
   702          const hovered = (mousePos && withinTolerance(marker.rate, tools.unx(mousePos.x), tolerance))
   703          if (hovered) hoverMarkers.push(marker.rate)
   704          ctx.save()
   705          ctx.lineWidth = (hovered || marker.active) ? 5 : 3
   706          ctx.strokeStyle = marker.sell ? this.theme.sellLine : this.theme.buyLine
   707          ctx.fillStyle = marker.sell ? this.theme.sellFill : this.theme.buyFill
   708          const size = (hovered || marker.active) ? 10 : 8
   709          ctx.beginPath()
   710          const tip = {
   711            x: tools.x(marker.rate),
   712            y: tools.y(marker.qty) - 8
   713          }
   714          const top = tip.y - (Math.sqrt(3) * size / 2) // cos(30)
   715          ctx.moveTo(tip.x, tip.y)
   716          ctx.lineTo(tip.x - size / 2, top)
   717          ctx.lineTo(tip.x + size / 2, top)
   718          ctx.closePath()
   719          ctx.stroke()
   720          ctx.fill()
   721          ctx.restore()
   722        }
   723  
   724        // If the mouse is in the chart area, draw the crosshairs.
   725        if (!mousePos) return
   726        if (!this.plotRegion.contains(mousePos.x, mousePos.y)) return
   727        // The mouse is in the plot region. Get the data coordinates and find the
   728        // side and depth for the x value.
   729        const dataX = tools.unx(mousePos.x)
   730        let evalSide = sellDepth
   731        let trigger = (ptX: number) => ptX >= dataX
   732        let dotColor = this.theme.sellLine
   733        if (dataX < midGap) {
   734          evalSide = buyDepth
   735          trigger = (ptX) => ptX <= dataX
   736          dotColor = this.theme.buyLine
   737        }
   738        let bestDepth = evalSide[0]
   739        for (let i = 0; i < evalSide.length; i++) {
   740          const pt = evalSide[i]
   741          if (trigger(pt[0])) break
   742          bestDepth = pt
   743        }
   744        drawLine(dataX, this.theme.crosshairs)
   745        mouseData = {
   746          rate: dataX,
   747          depth: bestDepth[1],
   748          dotColor: dotColor,
   749          hoverMarkers: hoverMarkers
   750        }
   751      })
   752  
   753      // Draw the epoch lines
   754      ctx.lineWidth = 1.5
   755      ctx.setLineDash([3, 3])
   756      // epoch sells
   757      ctx.fillStyle = this.theme.sellFill
   758      ctx.strokeStyle = this.theme.sellLine
   759      this.drawDepth(sellEpoch)
   760      // epoch buys
   761      ctx.fillStyle = this.theme.buyFill
   762      ctx.strokeStyle = this.theme.buyLine
   763      this.drawDepth(buyEpoch)
   764  
   765      // Draw the book depth.
   766      ctx.lineWidth = 2.5
   767      ctx.setLineDash([])
   768      // book sells
   769      ctx.fillStyle = this.theme.sellFill
   770      ctx.strokeStyle = this.theme.sellLine
   771      this.drawDepth(sellDepth)
   772      // book buys
   773      ctx.fillStyle = this.theme.buyFill
   774      ctx.strokeStyle = this.theme.buyLine
   775      this.drawDepth(buyDepth)
   776  
   777      this.plotYLabels(yLabels, this.dataExtents.y.min, this.dataExtents.y.max, this.baseUnit)
   778      this.plotXLabels(xLabels, low, high, [`${this.quoteUnit}/`, this.baseUnit])
   779  
   780      // Display the dot at the intersection of the mouse hover line and the depth
   781      // line. This should be drawn after the depths.
   782      if (mouseData) {
   783        this.plotRegion.plot(dataExtents, (ctx, tools) => {
   784          if (!mouseData) return // For TypeScript. Duh.
   785          dot(ctx, tools.x(mouseData.rate), tools.y(mouseData.depth), mouseData.dotColor, 5)
   786        })
   787      }
   788  
   789      // Report the book volumes.
   790      this.reporters.volume(volumeReport)
   791      this.reporters.mouse(mouseData)
   792    }
   793  
   794    /* drawDepth draws a single side's depth chart data. */
   795    drawDepth (depth: [number, number][]) {
   796      const firstPt = depth[0]
   797      let x: number
   798      this.plotRegion.plot(this.dataExtents, (ctx, tools) => {
   799        const yZero = tools.y(0)
   800        let y = tools.y(firstPt[1])
   801        ctx.beginPath()
   802        ctx.moveTo(tools.x(firstPt[0]), tools.y(firstPt[1]))
   803        for (let i = 0; i < depth.length; i++) {
   804          // Set x, but don't set y until we draw the horizontal line.
   805          x = tools.x(depth[i][0])
   806          ctx.lineTo(x, y)
   807          // If this is past the render edge, quit drawing.
   808          y = tools.y(depth[i][1])
   809          ctx.lineTo(x, y)
   810        }
   811        ctx.stroke()
   812        ctx.lineTo(x, yZero)
   813        ctx.lineTo(tools.x(firstPt[0]), yZero)
   814        ctx.closePath()
   815        ctx.globalAlpha = 0.25
   816        ctx.fill()
   817      })
   818    }
   819  
   820    /* returns the mid-gap rate and gap width as a tuple. */
   821    gap () {
   822      const [b, s] = [this.book.bestGapBuy(), this.book.bestGapSell()]
   823      if (!b) {
   824        if (!s) return [1, 0]
   825        return [s.rate, 0]
   826      } else if (!s) return [b.rate, 0]
   827      return [(s.rate + b.rate) / 2, s.rate - b.rate]
   828    }
   829  
   830    /* setLines stores the indicator lines to draw. */
   831    setLines (lines: DepthLine[]) {
   832      this.lines = lines
   833    }
   834  
   835    /* setMarkers sets the indicator markers to draw. */
   836    setMarkers (markers: Record<string, DepthMarker[]>) {
   837      this.markers = markers
   838    }
   839  }
   840  
   841  /* CandleChart is a candlestick data renderer. */
   842  export class CandleChart extends Chart {
   843    reporters: CandleReporters
   844    data: CandlesPayload
   845    zoomLevel: number
   846    numToShow: number
   847    candleRegion: Region
   848    volumeRegion: Region
   849    resizeTimer: number
   850    zoomLevels: number[]
   851    market: Market
   852    rateConversionFactor: number
   853  
   854    constructor (parent: HTMLElement, reporters: CandleReporters) {
   855      super(parent, {
   856        resize: () => this.resized(),
   857        click: (/* e: MouseEvent */) => { this.clicked() },
   858        zoom: (bigger: boolean) => this.zoomed(bigger)
   859      })
   860      this.reporters = reporters
   861      this.zoomLevel = 1
   862      this.numToShow = 100
   863      this.resize()
   864    }
   865  
   866    /* resized is called when the window or parent element are resized. */
   867    resized () {
   868      const ext = this.plotRegion.extents
   869      const candleExtents = new Extents(ext.x.min, ext.x.max, ext.y.min, ext.y.min + ext.yRange * 0.85)
   870      this.candleRegion = new Region(this.ctx, candleExtents)
   871      const volumeExtents = new Extents(ext.x.min, ext.x.max, ext.y.min + 0.85 * ext.yRange, ext.y.max)
   872      this.volumeRegion = new Region(this.ctx, volumeExtents)
   873      // Set a delay on the render to prevent lag.
   874      if (this.resizeTimer) clearTimeout(this.resizeTimer)
   875      this.resizeTimer = window.setTimeout(() => this.draw(), 100)
   876    }
   877  
   878    clicked (/* e: MouseEvent */) {
   879      // handle clicks
   880    }
   881  
   882    /* zoomed zooms the current view in or out. bigger=true is zoom in. */
   883    zoomed (bigger: boolean) {
   884      // bigger actually means fewer candles -> reduce zoomLevels index.
   885      const idx = this.zoomLevels.indexOf(this.numToShow)
   886      if (bigger) {
   887        if (idx === 0) return
   888        this.numToShow = this.zoomLevels[idx - 1]
   889      } else {
   890        if (this.zoomLevels.length <= idx + 1 || this.numToShow > this.data.candles.length) return
   891        this.numToShow = this.zoomLevels[idx + 1]
   892      }
   893      this.draw()
   894    }
   895  
   896    /* render draws the chart */
   897    render () {
   898      const data = this.data
   899      if (!data || !this.visible || this.canvas.width === 0) {
   900        this.renderScheduled = true
   901        return
   902      }
   903      const candleWidth = data.ms
   904      const mousePos = this.mousePos
   905      const allCandles = data.candles || []
   906  
   907      const n = Math.min(this.numToShow, allCandles.length)
   908      const candles = allCandles.slice(allCandles.length - n)
   909  
   910      this.clear()
   911  
   912      // If there are no candles. just don't draw anything.
   913      if (n === 0) return
   914  
   915      // padding definition and some helper functions to parse candles.
   916      const candleWidthPadding = 0.2
   917      const start = (c: Candle) => truncate(c.endStamp, candleWidth)
   918      const end = (c: Candle) => start(c) + candleWidth
   919      const paddedStart = (c: Candle) => start(c) + candleWidthPadding * candleWidth
   920      const paddedWidth = (1 - 2 * candleWidthPadding) * candleWidth
   921  
   922      const first = candles[0]
   923      const last = candles[n - 1]
   924  
   925      let [high, low, highVol] = [first.highRate, first.lowRate, first.matchVolume]
   926      for (const c of candles) {
   927        if (c.highRate > high) high = c.highRate
   928        if (c.lowRate < low) low = c.lowRate
   929        if (c.matchVolume > highVol) highVol = c.matchVolume
   930      }
   931  
   932      high += (high - low) * 0.1 // a little padding
   933      const xStart = start(first)
   934      let xEnd = end(last)
   935      xEnd += (xEnd - xStart) * 0.05 // a little padding
   936  
   937      // Calculate data extents and store them. They are used to apply labels.
   938      const rateStep = this.market.ratestep
   939      const dataExtents = new Extents(xStart, xEnd, low, high)
   940      if (low === high) {
   941        // If there is no price movement at all in the window, show a little more
   942        // top and bottom so things render nicely.
   943        dataExtents.y.min -= rateStep
   944        dataExtents.y.max += rateStep
   945      }
   946      this.dataExtents = dataExtents
   947  
   948      let mouseCandle: Candle | null = null
   949      if (mousePos) {
   950        this.plotRegion.plot(new Extents(dataExtents.x.min, dataExtents.x.max, 0, 1), (ctx, tools) => {
   951          const selectedStartStamp = truncate(tools.unx(mousePos.x), candleWidth)
   952          for (const c of candles) {
   953            if (start(c) === selectedStartStamp) {
   954              mouseCandle = c
   955              ctx.fillStyle = this.theme.gridLines
   956              ctx.fillRect(tools.x(start(c)), tools.y(0), tools.w(candleWidth), tools.h(1))
   957              break
   958            }
   959          }
   960        })
   961      }
   962  
   963      // Draw the grid
   964      const rFactor = this.rateConversionFactor
   965      const baseUnit = app().assets[this.market.baseid]?.unitInfo.conventional.unit || this.market.basesymbol.toUpperCase()
   966      const xLabels = makeCandleTimeLabels(candles, candleWidth, this.plotRegion.width(), 100)
   967      this.plotXGrid(xLabels, xStart, xEnd)
   968      const yLabels = this.makeYLabels(this.candleRegion, rateStep, baseUnit, v => Doc.formatFourSigFigs(v / rFactor))
   969      this.plotYGrid(this.candleRegion, yLabels, this.dataExtents.y.min, this.dataExtents.y.max)
   970  
   971      // Draw the volume bars.
   972      const volDataExtents = new Extents(xStart, xEnd, 0, highVol)
   973      this.volumeRegion.plot(volDataExtents, (ctx, tools) => {
   974        ctx.fillStyle = this.theme.gridBorder
   975        for (const c of candles) {
   976          ctx.fillRect(tools.x(paddedStart(c)), tools.y(0), tools.w(paddedWidth), tools.h(c.matchVolume))
   977        }
   978      })
   979  
   980      // Draw the candles.
   981      this.candleRegion.plot(dataExtents, (ctx, tools) => {
   982        ctx.lineWidth = 1
   983        for (const c of candles) {
   984          const desc = c.startRate > c.endRate
   985          const [x, y, w, h] = [tools.x(paddedStart(c)), tools.y(c.startRate), tools.w(paddedWidth), tools.h(c.endRate - c.startRate)]
   986          const [high, low, cx] = [tools.y(c.highRate), tools.y(c.lowRate), w / 2 + x]
   987          ctx.strokeStyle = desc ? this.theme.sellLine : this.theme.buyLine
   988          ctx.fillStyle = desc ? this.theme.sellFill : this.theme.buyFill
   989  
   990          ctx.beginPath()
   991          ctx.moveTo(cx, high)
   992          ctx.lineTo(cx, low)
   993          ctx.stroke()
   994  
   995          ctx.fillRect(x, y, w, h)
   996          ctx.strokeRect(x, y, w, h)
   997        }
   998      })
   999  
  1000      // Apply labels.
  1001      this.plotXLabels(xLabels, xStart, xEnd, [])
  1002      this.plotYLabels(yLabels, this.dataExtents.y.min, this.dataExtents.y.max, baseUnit)
  1003  
  1004      // Highlight the candle if the user mouse is over the canvas.
  1005      if (mouseCandle) {
  1006        const yExt = this.xRegion.extents.y
  1007        this.xRegion.plot(new Extents(dataExtents.x.min, dataExtents.x.max, yExt.min, yExt.max), (ctx, tools) => {
  1008          if (!mouseCandle) return // For TypeScript. Duh.
  1009          this.applyLabelStyle()
  1010          const rangeTxt = `${new Date(start(mouseCandle)).toLocaleString()} - ${new Date(end(mouseCandle)).toLocaleString()}`
  1011          const [xPad, yPad] = [25, 2]
  1012          const rangeWidth = ctx.measureText(rangeTxt).width + 2 * xPad
  1013          const rangeHeight = 16
  1014          let centerX = tools.x((start(mouseCandle) + end(mouseCandle)) / 2)
  1015          let left = centerX - rangeWidth / 2
  1016          const xExt = this.xRegion.extents.x
  1017          if (left < xExt.min) left = xExt.min
  1018          else if (left + rangeWidth > xExt.max) left = xExt.max - rangeWidth
  1019          centerX = left + rangeWidth / 2
  1020          const top = yExt.min + (this.xRegion.height() - rangeHeight) / 2
  1021          ctx.fillStyle = this.theme.legendFill
  1022          ctx.strokeStyle = this.theme.gridBorder
  1023          const rectArgs: [number, number, number, number] = [left - xPad, top - yPad, rangeWidth + 2 * xPad, rangeHeight + 2 * yPad]
  1024          ctx.fillRect(...rectArgs)
  1025          ctx.strokeRect(...rectArgs)
  1026          this.applyLabelStyle()
  1027          ctx.fillText(rangeTxt, centerX, this.xRegion.extents.midY, rangeWidth)
  1028        })
  1029      }
  1030  
  1031      // Report the mouse candle.
  1032      this.reporters.mouse(mouseCandle)
  1033    }
  1034  
  1035    /* setCandles sets the candle data and redraws the chart. */
  1036    setCandles (data: CandlesPayload, market: Market, baseUnitInfo: UnitInfo, quoteUnitInfo: UnitInfo) {
  1037      this.data = data
  1038      if (!data.candles) return
  1039      this.market = market
  1040      const [qFactor, bFactor] = [quoteUnitInfo.conventional.conversionFactor, baseUnitInfo.conventional.conversionFactor]
  1041      this.rateConversionFactor = RateEncodingFactor * qFactor / bFactor
  1042      let n = 25
  1043      this.zoomLevels = []
  1044      const maxCandles = Math.max(data.candles.length, 1000)
  1045      while (n < maxCandles) {
  1046        this.zoomLevels.push(n)
  1047        n *= 2
  1048      }
  1049      this.numToShow = 100
  1050      this.draw()
  1051    }
  1052  }
  1053  
  1054  interface WaveOpts {
  1055    message?: string
  1056    backgroundColor?: string | boolean // true for <body> background color
  1057  }
  1058  
  1059  /* Wave is a loading animation that displays a colorful line that oscillates */
  1060  export class Wave extends Chart {
  1061    ani: Animation
  1062    size: [number, number]
  1063    region: Region
  1064    colorShift: number
  1065    opts: WaveOpts
  1066    msgRegion: Region
  1067    fontSize: number
  1068  
  1069    constructor (parent: HTMLElement, opts?: WaveOpts) {
  1070      super(parent, {
  1071        resize: () => this.resized(),
  1072        click: (/* e: MouseEvent */) => { /* pass */ },
  1073        zoom: (/* bigger: boolean */) => { /* pass */ }
  1074      })
  1075      this.canvas.classList.add('fill-abs')
  1076      this.canvas.style.zIndex = '5'
  1077  
  1078      this.opts = opts ?? {}
  1079  
  1080      const period = 1500 // ms
  1081      const start = Math.random() * period
  1082      this.colorShift = Math.random() * 360
  1083  
  1084      // y = A*cos(k*x + theta*t + c)
  1085      // combine three waves with different periods and speeds and phases.
  1086      const amplitudes = [1, 0.65, 0.75]
  1087      const ks = [3, 3, 2]
  1088      const speeds = [Math.PI, Math.PI * 10 / 9, Math.PI / 2.5]
  1089      const phases = [0, 0, Math.PI * 1.5]
  1090      const n = 75
  1091      const single = (n: number, angularX: number, angularTime: number): number => {
  1092        return amplitudes[n] * Math.cos(ks[n] * angularX + speeds[n] * angularTime + phases[n])
  1093      }
  1094      const value = (x: number, angularTime: number): number => {
  1095        const angularX = x * Math.PI * 2
  1096        return (single(0, angularX, angularTime) + single(1, angularX, angularTime) + single(2, angularX, angularTime)) / 3
  1097      }
  1098      this.resize()
  1099      this.ani = new Animation(Animation.Forever, () => {
  1100        const angularTime = (new Date().getTime() - start) / period * Math.PI * 2
  1101        const values = []
  1102        for (let i = 0; i < n; i++) {
  1103          values.push(value(i / (n - 1), angularTime))
  1104        }
  1105        this.drawValues(values)
  1106      })
  1107    }
  1108  
  1109    resized () {
  1110      const opts = this.opts
  1111      const [maxW, maxH] = [150, 100]
  1112      const [cw, ch] = [this.canvas.width, this.canvas.height]
  1113      let [w, h] = [cw * 0.8, ch * 0.8]
  1114      if (w > maxW) w = maxW
  1115      if (h > maxH) h = maxH
  1116      let [l, t] = [(cw - w) / 2, (ch - h) / 2]
  1117      if (opts.message) {
  1118        this.fontSize = clamp(h * 0.15, 10, 14)
  1119        this.applyLabelStyle(this.fontSize)
  1120        const ypad = this.fontSize * 0.5
  1121        const halfH = (this.fontSize / 2) + ypad
  1122        t -= halfH
  1123        this.msgRegion = new Region(this.ctx, new Extents(0, cw, t + h, t + h + 2 * halfH))
  1124      }
  1125      this.region = new Region(this.ctx, new Extents(l, l + w, t, t + h))
  1126    }
  1127  
  1128    drawValues (values: number[]) {
  1129      if (!this.region) return
  1130      this.clear()
  1131      const hsl = (h: number) => `hsl(${h}, 35%, 50%)`
  1132  
  1133      const { region, msgRegion, canvas: { width: w, height: h }, opts: { backgroundColor: bg, message: msg }, colorShift, ctx } = this
  1134  
  1135      if (bg) {
  1136        if (bg === true) ctx.fillStyle = State.isDark() ? '#0a1e34' : '#f0f0f0'
  1137        else ctx.fillStyle = bg
  1138        ctx.fillRect(0, 0, w, h)
  1139      }
  1140  
  1141      region.plot(new Extents(0, 1, -1, 1), (ctx: CanvasRenderingContext2D, t: Translator) => {
  1142        ctx.lineWidth = 4
  1143        ctx.lineCap = 'round'
  1144  
  1145        const shift = colorShift + (new Date().getTime() % 2000) / 2000 * 360 // colors move with frequency 1 / 2s
  1146        const grad = ctx.createLinearGradient(t.x(0), 0, t.x(1), 0)
  1147        grad.addColorStop(0, hsl(shift))
  1148        ctx.strokeStyle = grad
  1149  
  1150        ctx.beginPath()
  1151        ctx.moveTo(t.x(0), t.y(values[0]))
  1152        for (let i = 1; i < values.length; i++) {
  1153          const prog = i / (values.length - 1)
  1154          grad.addColorStop(prog, hsl(prog * 300 + shift))
  1155          ctx.lineTo(t.x(prog), t.y(values[i]))
  1156        }
  1157        ctx.stroke()
  1158      })
  1159      if (!msg) return
  1160      msgRegion.plot(new Extents(0, 1, 0, 1), (ctx: CanvasRenderingContext2D, t: Translator) => {
  1161        this.applyLabelStyle(this.fontSize)
  1162        ctx.fillText(msg, t.x(0.5), t.y(0.5), this.msgRegion.width())
  1163      })
  1164    }
  1165  
  1166    render () { /* pass */ }
  1167  
  1168    stop () {
  1169      this.ani.stop()
  1170      this.canvas.remove()
  1171    }
  1172  }
  1173  
  1174  /*
  1175   * Extents holds a min and max in both the x and y directions, and provides
  1176   * getters for related data.
  1177   */
  1178  export class Extents {
  1179    x: MinMax
  1180    y: MinMax
  1181  
  1182    constructor (xMin: number, xMax: number, yMin: number, yMax: number) {
  1183      this.setExtents(xMin, xMax, yMin, yMax)
  1184    }
  1185  
  1186    setExtents (xMin: number, xMax: number, yMin: number, yMax: number) {
  1187      this.x = {
  1188        min: xMin,
  1189        max: xMax
  1190      }
  1191      this.y = {
  1192        min: yMin,
  1193        max: yMax
  1194      }
  1195    }
  1196  
  1197    get xRange (): number {
  1198      return this.x.max - this.x.min
  1199    }
  1200  
  1201    get midX (): number {
  1202      return (this.x.max + this.x.min) / 2
  1203    }
  1204  
  1205    get yRange (): number {
  1206      return this.y.max - this.y.min
  1207    }
  1208  
  1209    get midY (): number {
  1210      return (this.y.max + this.y.min) / 2
  1211    }
  1212  }
  1213  
  1214  /*
  1215   * Region applies an Extents to the canvas, providing utilities for coordinate
  1216   * transformations and restricting drawing to a specified region of the canvas.
  1217   */
  1218  export class Region {
  1219    context: CanvasRenderingContext2D
  1220    extents: Extents
  1221  
  1222    constructor (context: CanvasRenderingContext2D, extents: Extents) {
  1223      this.context = context
  1224      this.extents = extents
  1225    }
  1226  
  1227    setExtents (xMin: number, xMax: number, yMin: number, yMax: number) {
  1228      this.extents.setExtents(xMin, xMax, yMin, yMax)
  1229    }
  1230  
  1231    width (): number {
  1232      return this.extents.xRange
  1233    }
  1234  
  1235    height (): number {
  1236      return this.extents.yRange
  1237    }
  1238  
  1239    contains (x: number, y: number): boolean {
  1240      const ext = this.extents
  1241      return (x < ext.x.max && x > ext.x.min &&
  1242        y < ext.y.max && y > ext.y.min)
  1243    }
  1244  
  1245    /*
  1246     * A translator provides 4 function for coordinate transformations. x and y
  1247     * translate data coordinates to canvas coordinates for the specified data
  1248     * Extents. unx and uny translate canvas coordinates to data coordinates.
  1249     */
  1250    translator (dataExtents: Extents): Translator {
  1251      const region = this.extents
  1252      const xMin = dataExtents.x.min
  1253      // const xMax = dataExtents.x.max
  1254      const yMin = dataExtents.y.min
  1255      // const yMax = dataExtents.y.max
  1256      const yRange = dataExtents.yRange
  1257      const xRange = dataExtents.xRange
  1258      const screenMinX = region.x.min
  1259      const screenW = region.x.max - screenMinX
  1260      const screenMaxY = region.y.max
  1261      const screenH = screenMaxY - region.y.min
  1262      const xFactor = screenW / xRange
  1263      const yFactor = screenH / yRange
  1264      return {
  1265        x: (x: number) => (x - xMin) * xFactor + screenMinX,
  1266        y: (y: number) => screenMaxY - (y - yMin) * yFactor,
  1267        unx: (x: number) => (x - screenMinX) / xFactor + xMin,
  1268        uny: (y: number) => yMin - (y - screenMaxY) / yFactor,
  1269        w: (w: number) => w / xRange * screenW,
  1270        h: (h: number) => -h / yRange * screenH
  1271      }
  1272    }
  1273  
  1274    /* clear clears the region. */
  1275    clear () {
  1276      const ext = this.extents
  1277      this.context.clearRect(ext.x.min, ext.y.min, ext.xRange, ext.yRange)
  1278    }
  1279  
  1280    /* plot prepares tools for drawing using data coordinates. */
  1281    plot (dataExtents: Extents, drawFunc: (ctx: CanvasRenderingContext2D, tools: Translator) => void, skipMask?: boolean) {
  1282      const ctx = this.context
  1283      const region = this.extents
  1284      ctx.save() // Save the original state
  1285      if (!skipMask) {
  1286        ctx.beginPath()
  1287        ctx.rect(region.x.min, region.y.min, region.xRange, region.yRange)
  1288        ctx.clip()
  1289      }
  1290  
  1291      // The drawFunc will be passed a set of tool that can be used to assist
  1292      // drawing. The tools start with the transformation functions.
  1293      const tools = this.translator(dataExtents)
  1294  
  1295      // Create a transformation that allows drawing in data coordinates. It's
  1296      // not advisable to stroke or add text with this transform in place, as the
  1297      // result will be distorted. You can however use ctx.moveTo and ctx.lineTo
  1298      // with this transform in place using data coordinates, and remove the
  1299      // transform before stroking. The dataCoords method of the supplied tool
  1300      // provides this functionality.
  1301  
  1302      // TODO: Figure out why this doesn't work on WebView.
  1303      // const yRange = dataExtents.yRange
  1304      // const xFactor = region.xRange / dataExtents.xRange
  1305      // const yFactor = region.yRange / yRange
  1306      // const xMin = dataExtents.x.min
  1307      // const yMin = dataExtents.y.min
  1308      // // These translation factors are complicated because the (0, 0) of the
  1309      // // region is not necessarily the (0, 0) of the canvas.
  1310      // const tx = (region.x.min + xMin) - xMin * xFactor
  1311      // const ty = -region.y.min - (yRange - yMin) * yFactor
  1312      // const setTransform = () => {
  1313      //   // Data coordinates are flipped about y. Flip the coordinates and
  1314      //   // translate top left corner to canvas (0, 0).
  1315      //   ctx.transform(1, 0, 0, -1, -xMin, yMin)
  1316      //   // Scale to data coordinates and shift into place for the region's offset
  1317      //   // on the canvas.
  1318      //   ctx.transform(xFactor, 0, 0, yFactor, tx, ty)
  1319      // }
  1320      // // dataCoords allows some drawing to be performed directly in data
  1321      // // coordinates. Most actual drawing functions like ctx.stroke and
  1322      // // ctx.fillRect should not be called from inside dataCoords, but
  1323      // // ctx.moveTo and ctx.LineTo are fine.
  1324      // tools.dataCoords = f => {
  1325      //   ctx.save()
  1326      //   setTransform()
  1327      //   f()
  1328      //   ctx.restore()
  1329      // }
  1330  
  1331      drawFunc(this.context, tools)
  1332      ctx.restore()
  1333    }
  1334  }
  1335  
  1336  /*
  1337   * makeLabels attempts to create the appropriate labels for the specified
  1338   * screen size, context, and label spacing.
  1339   */
  1340  function makeLabels (
  1341    ctx: CanvasRenderingContext2D,
  1342    screenW: number,
  1343    min: number,
  1344    max: number,
  1345    spacingGuess: number,
  1346    step: number,
  1347    unit: string,
  1348    valFmt?: (v: number) => string
  1349  ): LabelSet {
  1350    valFmt = valFmt || Doc.formatFourSigFigs
  1351    const n = screenW / spacingGuess
  1352    const diff = max - min
  1353    if (n < 1 || diff <= 0) return { lbls: [] }
  1354    const tickGuess = diff / n
  1355    // make the tick spacing a multiple of the step
  1356    const tick = tickGuess + step - (tickGuess % step)
  1357    let x = min + tick - (min % tick)
  1358    const absMax = Math.max(Math.abs(max), Math.abs(min))
  1359    // The Math.round part is the minimum precision required to see the change in the numbers.
  1360    // The 2 accounts for the precision of the tick.
  1361    const sigFigs = Math.round(Math.log10(absMax / tick)) + 2
  1362    const pts: Label[] = []
  1363    let widest = 0
  1364    while (x < max) {
  1365      x = Number(x.toPrecision(sigFigs))
  1366      const lbl = valFmt(x)
  1367      widest = Math.max(widest, ctx.measureText(lbl).width)
  1368      pts.push({
  1369        val: x,
  1370        txt: lbl
  1371      })
  1372      x += tick
  1373    }
  1374    const unitW = ctx.measureText(unit).width
  1375    if (unitW > widest) widest = unitW
  1376    return {
  1377      widest: widest,
  1378      lbls: pts
  1379    }
  1380  }
  1381  
  1382  const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']
  1383  
  1384  /* makeCandleTimeLabels prepares labels for candlestick data. */
  1385  function makeCandleTimeLabels (candles: Candle[], dur: number, screenW: number, spacingGuess: number): LabelSet {
  1386    const first = candles[0]
  1387    const last = candles[candles.length - 1]
  1388    const start = truncate(first.endStamp, dur)
  1389    const end = truncate(last.endStamp, dur) + dur
  1390    const diff = end - start
  1391    const n = Math.min(candles.length, screenW / spacingGuess)
  1392    const tick = truncate(diff / n, dur)
  1393    if (tick === 0) {
  1394      console.error('zero tick', dur, diff, n) // probably won't happen, but it'd suck if it did
  1395      return { lbls: [] }
  1396    }
  1397    let x = start
  1398    const zoneOffset = new Date().getTimezoneOffset()
  1399    const dayStamp = (x: number) => {
  1400      x = x - zoneOffset * 60000
  1401      return x - (x % 86400000)
  1402    }
  1403    let lastDay = dayStamp(start)
  1404    let lastYear = 0 // new Date(start).getFullYear()
  1405    if (dayStamp(first.endStamp) === dayStamp(last.endStamp)) lastDay = 0 // Force at least one day stamp.
  1406    const pts = []
  1407    let label
  1408    if (dur < 86400000) {
  1409      label = (d: Date, x: number) => {
  1410        const day = dayStamp(x)
  1411        if (day !== lastDay) return `${months[d.getMonth()]}${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2, '0')}`
  1412        else return `${d.getHours()}:${String(d.getMinutes()).padStart(2, '0')}`
  1413      }
  1414    } else {
  1415      label = (d: Date) => {
  1416        const year = d.getFullYear()
  1417        if (year !== lastYear) return `${months[d.getMonth()]}${d.getDate()} '${String(year).slice(2, 4)}`
  1418        else return `${months[d.getMonth()]}${d.getDate()}`
  1419      }
  1420    }
  1421    while (x <= end) {
  1422      const d = new Date(x)
  1423      pts.push({
  1424        val: x,
  1425        txt: label(d, x)
  1426      })
  1427      lastDay = dayStamp(x)
  1428      lastYear = d.getFullYear()
  1429      x += tick
  1430    }
  1431    return { lbls: pts }
  1432  }
  1433  
  1434  /* The last element of an array. */
  1435  function last (arr: any[]): any {
  1436    return arr[arr.length - 1]
  1437  }
  1438  
  1439  /* line draws a line with the provided context. */
  1440  function line (ctx: CanvasRenderingContext2D, x0: number, y0: number, x1: number, y1: number, skipStroke?: boolean) {
  1441    ctx.beginPath()
  1442    ctx.moveTo(x0, y0)
  1443    ctx.lineTo(x1, y1)
  1444    if (!skipStroke) ctx.stroke()
  1445  }
  1446  
  1447  /* dot draws a circle with the provided context. */
  1448  function dot (ctx: CanvasRenderingContext2D, x: number, y: number, color: string, radius: number) {
  1449    ctx.fillStyle = color
  1450    ctx.beginPath()
  1451    ctx.arc(x, y, radius, 0, PIPI)
  1452    ctx.fill()
  1453  }
  1454  
  1455  /* floatCompare compares two floats to within a tolerance of  1e-8. */
  1456  function floatCompare (a: number, b: number) {
  1457    return withinTolerance(a, b, 1e-8)
  1458  }
  1459  
  1460  /*
  1461   * withinTolerance returns true if the difference between a and b are with
  1462   * the specified tolerance.
  1463   */
  1464  function withinTolerance (a: number, b: number, tolerance: number) {
  1465    return Math.abs(a - b) < Math.abs(tolerance)
  1466  }
  1467  
  1468  function truncate (v: number, w: number): number {
  1469    return v - (v % w)
  1470  }