decred.org/dcrdex@v1.0.3/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 }