diff --git a/package.json b/package.json index f48d27a1..bca23657 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ }, "dependencies": { "color": "^4.2.3", + "comlink": "4.4.1", "date-fns": "^2.30.0", "rxjs": "^7.5.7" }, diff --git a/src/chart/bootstrap.ts b/src/chart/bootstrap.ts index 0854b96e..338020ea 100755 --- a/src/chart/bootstrap.ts +++ b/src/chart/bootstrap.ts @@ -59,6 +59,7 @@ import { clearerSafe } from './utils/function.utils'; import { merge } from './utils/merge.utils'; import { DeepPartial } from './utils/object.utils'; import { HitTestComponent } from './components/hit-test/hit-test.component'; +import { isOffscreenWorkerAvailable } from './canvas/offscreen/init-offscreen'; export type FitType = 'studies' | 'orders' | 'positions'; @@ -190,38 +191,70 @@ export default class ChartBootstrap { this.canvasModels, config, ); - this.chartResizeHandler = chartResizeHandler; - chartResizeHandler.subscribeResize(); - this.components.push(chartResizeHandler.unsubscribeAnimationUpdate.bind(chartResizeHandler)); - const drawingManager = new DrawingManager(eventBus, chartResizeHandler); - this.drawingManager = drawingManager; + + const offscreenEnabled = isOffscreenWorkerAvailable && config.experimental.offscreen.enabled; + + const backgroundCanvasModel = createCanvasModel( + eventBus, + elements.backgroundCanvas, + config, + this.canvasModels, + elements.chartResizer, + { + offscreen: offscreenEnabled, + offscreenBufferSize: config.experimental.offscreen.bufferSizes.backgroundCanvas, + }, + ); + this.backgroundCanvasModel = backgroundCanvasModel; const mainCanvasModel = createMainCanvasModel( eventBus, elements.mainCanvas, elements.chartResizer, this.config.components.chart.type, - this.config, - drawingManager, + config, this.canvasModels, + { offscreen: offscreenEnabled, offscreenBufferSize: config.experimental.offscreen.bufferSizes.mainCanvas }, ); this.mainCanvasModel = mainCanvasModel; this.dynamicObjectsCanvasModel = createCanvasModel( eventBus, elements.dynamicObjectsCanvas, config, - drawingManager, this.canvasModels, elements.chartResizer, + { offscreen: offscreenEnabled, offscreenBufferSize: config.experimental.offscreen.bufferSizes.dynamicObjectsCanvas }, + ); + const crossToolCanvasModel = createCanvasModel( + eventBus, + elements.crossToolCanvas, + config, + this.canvasModels, + elements.chartResizer, + { offscreen: offscreenEnabled, offscreenBufferSize: config.experimental.offscreen.bufferSizes.crossToolCanvas }, ); + const snapshotCanvasModel = createCanvasModel( + eventBus, + elements.snapshotCanvas, + config, + this.canvasModels, + elements.chartResizer, + { offscreen: offscreenEnabled, offscreenBufferSize: config.experimental.offscreen.bufferSizes.snapshotCanvas }, + ); + this.chartResizeHandler = chartResizeHandler; + chartResizeHandler.subscribeResize(); + this.components.push(chartResizeHandler.unsubscribeAnimationUpdate.bind(chartResizeHandler)); + const drawingManager = new DrawingManager(config, eventBus, chartResizeHandler, this.canvasModels); + this.drawingManager = drawingManager; + const dataSeriesCanvasClearDrawer = new ClearCanvasDrawer(this.dynamicObjectsCanvasModel); drawingManager.addDrawer(dataSeriesCanvasClearDrawer, 'SERIES_CLEAR'); const yAxisLabelsCanvasModel = createCanvasModel( eventBus, elements.yAxisLabelsCanvas, config, - drawingManager, this.canvasModels, elements.chartResizer, + { offscreen: offscreenEnabled, offscreenBufferSize: config.experimental.offscreen.bufferSizes.yAxisLabelsCanvas }, ); const canvasBoundsContainer = new CanvasBoundsContainer( config, @@ -246,7 +279,6 @@ export default class ChartBootstrap { elements.hitTestCanvas, canvasInputListener, canvasBoundsContainer, - drawingManager, config, this.canvasModels, elements.chartResizer, @@ -266,20 +298,6 @@ export default class ChartBootstrap { this.scaleModel = scaleModel; //#endregion - const backgroundCanvasModel = createCanvasModel( - eventBus, - elements.backgroundCanvas, - config, - drawingManager, - this.canvasModels, - elements.chartResizer, - { - // can be read frequently, see {redrawBackgroundArea} function - willReadFrequently: true, - }, - ); - this.backgroundCanvasModel = backgroundCanvasModel; - this.cursorHandler = new CursorHandler( elements.canvasArea, canvasInputListener, @@ -409,14 +427,6 @@ export default class ChartBootstrap { drawingManager, ); this.chartComponents.push(this.watermarkComponent); - const crossToolCanvasModel = createCanvasModel( - eventBus, - elements.crossToolCanvas, - config, - drawingManager, - this.canvasModels, - elements.chartResizer, - ); this.highlightsComponent = new HighlightsComponent( eventBus, config, @@ -521,14 +531,6 @@ export default class ChartBootstrap { this.chartComponents.push(this.crossToolComponent); // Snapshot component - const snapshotCanvasModel = createCanvasModel( - eventBus, - elements.snapshotCanvas, - config, - drawingManager, - this.canvasModels, - elements.chartResizer, - ); const snapshotComponent = new SnapshotComponent(this.elements, snapshotCanvasModel); this.snapshotComponent = snapshotComponent; this.chartComponents.push(snapshotComponent); diff --git a/src/chart/canvas/canvas-bounds-container.ts b/src/chart/canvas/canvas-bounds-container.ts index cff40ea1..44e677c1 100644 --- a/src/chart/canvas/canvas-bounds-container.ts +++ b/src/chart/canvas/canvas-bounds-container.ts @@ -120,7 +120,7 @@ export class CanvasBoundsContainer { this.updateCanvasOnPageLocation(calculatedBCR); this.recalculateBounds(); }); - this.yAxisBoundsContainer = new YAxisBoundsContainer(this.config, this.canvasModel); + this.yAxisBoundsContainer = new YAxisBoundsContainer(this.config); } public updateYAxisWidths() { @@ -438,7 +438,7 @@ export class CanvasBoundsContainer { getXAxisHeight() { if (!this.xAxisHeight) { const font = this.config.components.xAxis.fontSize + 'px ' + this.config.components.xAxis.fontFamily; - const fontHeight = calculateSymbolHeight(font, this.canvasModel.ctx); + const fontHeight = calculateSymbolHeight(font); this.xAxisHeight = fontHeight + (this.config.components.xAxis.padding.top ?? 0) + diff --git a/src/chart/canvas/cursor.handler.ts b/src/chart/canvas/cursor.handler.ts index a895e281..e4fffa78 100644 --- a/src/chart/canvas/cursor.handler.ts +++ b/src/chart/canvas/cursor.handler.ts @@ -43,8 +43,8 @@ export class CursorHandler extends ChartBaseElement { this.canvasInputListener .observeMouseMoveNoDrag() .pipe(throttleTime(100, animationFrameScheduler, { trailing: true })) - .subscribe(point => { - const cursorFromHT = this.hitTestCanvasModel.resolveCursor(point); + .subscribe(async point => { + const cursorFromHT = await this.hitTestCanvasModel.resolveCursor(point); if (cursorFromHT !== undefined) { this.updateCursor(cursorFromHT); return; diff --git a/src/chart/canvas/offscreen/canvas-ctx.mapper.js b/src/chart/canvas/offscreen/canvas-ctx.mapper.js new file mode 100644 index 00000000..c5e36bb7 --- /dev/null +++ b/src/chart/canvas/offscreen/canvas-ctx.mapper.js @@ -0,0 +1,90 @@ +// #region canvas commands encodings - see commands mappings to methods below +export const FONT = 0; +export const WIDTH = 1; +export const HEIGHT = 2; +export const FILL_STYLE = 3; +export const STROKE_STYLE = 4; +export const LINE_WIDTH = 5; +export const LINE_CAP = 6; +export const SAVE = 7; +export const RESTORE = 8; +export const MEASURE_TEXT = 9; +export const CLEAR_RECT = 10; +export const FILL_RECT = 11; +export const STROKE_TEXT = 12; +export const SET_LINE_DASH = 13; +export const FILL_TEXT = 14; +export const RECT = 15; +export const CLIP = 16; +export const QUADRATIC_CURVE_TO = 17; +export const BEZIER_CURVE_TO = 18; +export const BEGIN_PATH = 19; +export const MOVE_TO = 20; +export const LINE_TO = 21; +export const STROKE = 22; +export const FILL = 23; +export const CLOSE_PATH = 24; +export const STROKE_RECT = 25; +export const SCALE = 26; +export const NOP = 27; +export const ARC = 28; +// custom method +export const SET_LINE_DASH_FLAT = 29; +// custom method +export const SET_GRADIENT_FILL_STYLE = 30; +// custom method +export const REDRAW_BACKGROUND_AREA = 31; +export const TRANSLATE = 32; +export const TEXT_BASELINE = 33; +export const TEXT_ALIGN = 34; +export const ROTATE = 35; +export const LINE_JOIN = 36; +export const DIRECTION = 37; +export const FONT_KERNING = 38; + +// Special command which indicates the end of canvas commands inside the buffer +export const END_OF_FILE = 0xdead; +// #endregion + +// Map from command number to canvas context method name +export const num2Ctx = [ + 'font', + 'width', + 'height', + 'fillStyle', + 'strokeStyle', + 'lineWidth', + 'lineCap', + 'save', + 'restore', + 'measureText', + 'clearRect', + 'fillRect', + 'strokeText', + 'setLineDash', + 'fillText', + 'rect', + 'clip', + 'quadraticCurveTo', + 'bezierCurveTo', + 'beginPath', + 'moveTo', + 'lineTo', + 'stroke', + 'fill', + 'closePath', + 'strokeRect', + 'scale', + 'nop', + 'arc', + 'setLineDashFlat', + 'setGradientFillStyle', + 'redrawBackgroundArea', + 'translate', + 'textBaseline', + 'textAlign', + 'rotate', + 'lineJoin', + 'direction', + 'fontKerning', +]; \ No newline at end of file diff --git a/src/chart/canvas/offscreen/canvas-offscreen-wrapper.ts b/src/chart/canvas/offscreen/canvas-offscreen-wrapper.ts new file mode 100644 index 00000000..9abd717a --- /dev/null +++ b/src/chart/canvas/offscreen/canvas-offscreen-wrapper.ts @@ -0,0 +1,612 @@ +import { CanvasModel } from '../../model/canvas.model'; +import { ctxForMeasure } from '../../utils/canvas/canvas-font-measure-tool.utils'; +import { + ARC, + BEGIN_PATH, + BEZIER_CURVE_TO, + CLEAR_RECT, + CLIP, + CLOSE_PATH, + DIRECTION, + END_OF_FILE, + FILL, + FILL_RECT, + FILL_STYLE, + FILL_TEXT, + FONT, + FONT_KERNING, + HEIGHT, + LINE_CAP, + LINE_JOIN, + LINE_TO, + LINE_WIDTH, + MOVE_TO, + NOP, + QUADRATIC_CURVE_TO, + RECT, + REDRAW_BACKGROUND_AREA, + RESTORE, + ROTATE, + SAVE, + SCALE, + SET_GRADIENT_FILL_STYLE, + SET_LINE_DASH_FLAT, + STROKE, + STROKE_RECT, + STROKE_STYLE, + STROKE_TEXT, + TEXT_ALIGN, + TEXT_BASELINE, + TRANSLATE, + WIDTH, +} from './canvas-ctx.mapper'; + +/** + * We use this counter to generate unique ids for strings. + * I've used Number.MIN_SAFE_INTEGER, because it's unlikely that we will have more than 2^53 strings + * and it's unlikely to have negative arguments (especially big ones) for canvas commands on chart. + */ +let curPtr = -1_000_000; +/** + * Global pool of strings which is used to synchronize strings between main thread and worker thread. + * It's intentional that this map is global, because we want to have common strings pool between all charts. + * The goal of this pool is to have a unique string id for each string and write this id to the shared buffer, + * so we don't have to encode and write the string to the buffer (encoding string is not an easy task, + * also on repeated encodings it'll be much faster since we write only one number instead of array of bytes for corresponding string). + */ +const stringPtrs = new Map(); +// an array of strings and their ids which should be synchronized between main thread and worker thread +// the format of data in this array is [str1, id1, str2, id2, ...] +export const strsToSync: Array = []; + +/** + * A canvas wrapper which partly implements CanvasRenderingContext2D interface. + * It's used to record all canvas commands from the main thread and store it in SharedArrayBuffer. + * When the main thread needs to render the chart, it sends the command to worker thread, which executes it on the offscreen canvas. + * SharedArrayBuffer is much faster than sending commands, because it's shared memory, so it's not copied between threads. + * + * In order to utilize SharedArrayBuffer, we need to use array wrapper (Float64Array in our case), so we can store only number. + * In order to encode commands and their arguments, we use a simple encoding scheme: + * First number is a command id, the second number is a number of arguments, and the rest are arguments. For instance [26, 2, 2, 2] which means "scale(2, 2)" + * Please refer to canvas-ctx.mapper.ts for the full list of commands and their encodings. + * + * However, there are some commands which can't be encoded in this way, for instance "fillStyle = 'red'". + * In this case we store the string in the global pool (strSync) and write the id (number) of the string to the buffer. + * After that, before the draw command we need to perform synchronization of this pool between main thread and worker thread. + */ +export class CanvasOffscreenContext2D implements CanvasRenderingContext2D { + public commands!: Float32Array; + public buffer!: SharedArrayBuffer; + /** + * Current canvas commands pointer which indicates position of the next command in the buffer. + */ + private counter = 0; + + private getStrPtr(str: string): number { + let id = stringPtrs.get(str); + if (id === undefined) { + id = --curPtr; + strsToSync.push(str, id); + stringPtrs.set(str, id); + } + return id; + } + + private __font: string = '12px Arial'; + + constructor(public canvas: HTMLCanvasElement) {} + + initBuffer(buffer: SharedArrayBuffer) { + this.buffer = buffer; + this.commands = new Float32Array(this.buffer); + this.commit(); + } + + commit() { + if (this.counter >= this.commands.length) { + console.error('Buffer overflow'); + this.commands[0] = END_OF_FILE; + } else { + this.commands[this.counter] = END_OF_FILE; + } + this.counter = 0; + } + + nop() { + this.commands[this.counter++] = NOP; + } + + set font(val: string) { + this.__font = val; + this.commands[this.counter++] = FONT; + this.commands[this.counter++] = -1; + this.commands[this.counter++] = this.getStrPtr(val); + } + + get font(): string { + return this.__font; + } + + set width(val: number) { + this.commands[this.counter++] = WIDTH; + this.commands[this.counter++] = -1; + this.commands[this.counter++] = val; + } + + set height(val: number) { + this.commands[this.counter++] = HEIGHT; + this.commands[this.counter++] = -1; + this.commands[this.counter++] = val; + } + + set fillStyle(val: string) { + this.commands[this.counter++] = FILL_STYLE; + this.commands[this.counter++] = -1; + this.commands[this.counter++] = this.getStrPtr(val); + } + + set strokeStyle(val: string) { + this.commands[this.counter++] = STROKE_STYLE; + this.commands[this.counter++] = -1; + this.commands[this.counter++] = this.getStrPtr(val); + } + + set lineWidth(val: number) { + this.commands[this.counter++] = LINE_WIDTH; + this.commands[this.counter++] = -1; + this.commands[this.counter++] = val; + } + + set lineCap(val: CanvasLineCap) { + this.commands[this.counter++] = LINE_CAP; + this.commands[this.counter++] = -1; + this.commands[this.counter++] = this.getStrPtr(val); + } + + public save(): void { + this.commands[this.counter++] = SAVE; + this.commands[this.counter++] = 0; + } + + public restore(): void { + this.commands[this.counter++] = RESTORE; + this.commands[this.counter++] = 0; + } + + public measureText(text: string): TextMetrics { + ctxForMeasure.font = this.__font; + return ctxForMeasure.measureText(text); + } + + public createLinearGradient(x0: number, y0: number, x1: number, y1: number): CanvasGradient { + return ctxForMeasure.createLinearGradient(x0, y0, x1, y1); + } + + putImageData(): void { + // this.commands[this.counter++] = PUT_IMAGE_DATA_FLAT; + // this.commands[this.counter++] = 2 + imagedata.data.length; + // this.commands[this.counter++] = dx; + // this.commands[this.counter++] = dy; + // for (let i = 0; i < imagedata.data.length; i++) { + // this.commands[this.counter++] = imagedata.data[i]; + // } + } + + public clearRect(x: number, y: number, w: number, h: number): void { + this.commands[this.counter++] = CLEAR_RECT; + this.commands[this.counter++] = 4; + this.commands[this.counter++] = x; + this.commands[this.counter++] = y; + this.commands[this.counter++] = w; + this.commands[this.counter++] = h; + } + + public fillRect(x: number, y: number, w: number, h: number): void { + this.commands[this.counter++] = FILL_RECT; + this.commands[this.counter++] = 4; + this.commands[this.counter++] = x; + this.commands[this.counter++] = y; + this.commands[this.counter++] = w; + this.commands[this.counter++] = h; + } + + public arc( + x: number, + y: number, + radius: number, + startAngle: number, + endAngle: number, + counterclockwise?: boolean | undefined, + ): void { + this.commands[this.counter++] = ARC; + this.commands[this.counter++] = counterclockwise === undefined ? 5 : 6; + this.commands[this.counter++] = x; + this.commands[this.counter++] = y; + this.commands[this.counter++] = radius; + this.commands[this.counter++] = startAngle; + this.commands[this.counter++] = endAngle; + if (counterclockwise !== undefined) { + this.commands[this.counter++] = counterclockwise ? 1 : 0; + } + } + + public strokeText(text: string, x: number, y: number, maxWidth?: number): void { + this.commands[this.counter++] = STROKE_TEXT; + this.commands[this.counter++] = maxWidth !== undefined ? 4 : 3; + this.commands[this.counter++] = this.getStrPtr(text); + this.commands[this.counter++] = x; + this.commands[this.counter++] = y; + if (maxWidth !== undefined) { + this.commands[this.counter++] = maxWidth; + } + } + + public setLineDash(dash: number[]): void { + this.__lineDash = dash; + this.commands[this.counter++] = SET_LINE_DASH_FLAT; + this.commands[this.counter++] = dash.length; + for (const el of dash) { + this.commands[this.counter++] = el; + } + } + + private __lineDash: number[] = []; + + public getLineDash(): number[] { + return this.__lineDash; + } + + public fillText(text: string, x: number, y: number, maxWidth?: number | undefined): void { + this.commands[this.counter++] = FILL_TEXT; + this.commands[this.counter++] = maxWidth !== undefined ? 4 : 3; + this.commands[this.counter++] = this.getStrPtr(text); + this.commands[this.counter++] = x; + this.commands[this.counter++] = y; + if (maxWidth !== undefined) { + this.commands[this.counter++] = maxWidth; + } + } + + public rect(x: number, y: number, w: number, h: number): void { + this.commands[this.counter++] = RECT; + this.commands[this.counter++] = 4; + this.commands[this.counter++] = x; + this.commands[this.counter++] = y; + this.commands[this.counter++] = w; + this.commands[this.counter++] = h; + } + + public clip(): void { + this.commands[this.counter++] = CLIP; + this.commands[this.counter++] = 0; + } + + public quadraticCurveTo(cpx: number, cpy: number, x: number, y: number): void { + this.commands[this.counter++] = QUADRATIC_CURVE_TO; + this.commands[this.counter++] = 4; + this.commands[this.counter++] = cpx; + this.commands[this.counter++] = cpy; + this.commands[this.counter++] = x; + this.commands[this.counter++] = y; + } + + public bezierCurveTo(cp1x: number, cp1y: number, cp2x: number, cp2y: number, x: number, y: number): void { + this.commands[this.counter++] = BEZIER_CURVE_TO; + this.commands[this.counter++] = 6; + this.commands[this.counter++] = cp1x; + this.commands[this.counter++] = cp1y; + this.commands[this.counter++] = cp2x; + this.commands[this.counter++] = cp2y; + this.commands[this.counter++] = x; + this.commands[this.counter++] = y; + } + + public getImageData(sx: number, sy: number, sw: number, sh: number): ImageData { + return new ImageData(sw, sh); + } + + public beginPath(): void { + this.commands[this.counter++] = BEGIN_PATH; + this.commands[this.counter++] = 0; + } + + public moveTo(x: number, y: number): void { + this.commands[this.counter++] = MOVE_TO; + this.commands[this.counter++] = 2; + this.commands[this.counter++] = x; + this.commands[this.counter++] = y; + } + + public lineTo(x: number, y: number): void { + this.commands[this.counter++] = LINE_TO; + this.commands[this.counter++] = 2; + this.commands[this.counter++] = x; + this.commands[this.counter++] = y; + } + + public stroke(): void { + this.commands[this.counter++] = STROKE; + this.commands[this.counter++] = 0; + } + + public fill(): void { + this.commands[this.counter++] = FILL; + this.commands[this.counter++] = 0; + } + + public closePath(): void { + this.commands[this.counter++] = CLOSE_PATH; + this.commands[this.counter++] = 0; + } + + public strokeRect(x: number, y: number, w: number, h: number): void { + this.commands[this.counter++] = STROKE_RECT; + this.commands[this.counter++] = 4; + this.commands[this.counter++] = x; + this.commands[this.counter++] = y; + this.commands[this.counter++] = w; + this.commands[this.counter++] = h; + } + + public scale(x: number, y: number): void { + this.commands[this.counter++] = SCALE; + this.commands[this.counter++] = 2; + this.commands[this.counter++] = x; + this.commands[this.counter++] = y; + } + + /** + * Special method for gradient fill, because we can't transfer CanvasGradient directly to offscreen. + * Is equivalent for following code: + * const grd = ctx.createLinearGradient(x, y, w, h); + * grd.addColorStop(offset0, color0); + * grd.addColorStop(offset1, color1); + * ctx.fillStyle = grd; + */ + public setGradientFillStyle( + x: number, + y: number, + w: number, + h: number, + offset0: number, + color0: string, + offset1: number, + color1: string, + ): void { + this.commands[this.counter++] = SET_GRADIENT_FILL_STYLE; + this.commands[this.counter++] = 8; + this.commands[this.counter++] = x; + this.commands[this.counter++] = y; + this.commands[this.counter++] = w; + this.commands[this.counter++] = h; + this.commands[this.counter++] = offset0; + this.commands[this.counter++] = this.getStrPtr(color0); + this.commands[this.counter++] = offset1; + this.commands[this.counter++] = this.getStrPtr(color1); + } + + public redrawBackgroundArea( + backgroundCtxIdx: number, + ctxIdx: number, + x: number, + y: number, + w: number, + h: number, + opacity?: number, + ): void { + this.commands[this.counter++] = REDRAW_BACKGROUND_AREA; + this.commands[this.counter++] = 7; + this.commands[this.counter++] = backgroundCtxIdx; + this.commands[this.counter++] = ctxIdx; + this.commands[this.counter++] = x; + this.commands[this.counter++] = y; + this.commands[this.counter++] = w; + this.commands[this.counter++] = h; + this.commands[this.counter++] = opacity ?? 1; + } + + translate(x: number, y: number): void { + this.commands[this.counter++] = TRANSLATE; + this.commands[this.counter++] = 2; + this.commands[this.counter++] = x; + this.commands[this.counter++] = y; + } + + private __textBaseline: CanvasTextBaseline = 'alphabetic'; + + set textBaseline(val: CanvasTextBaseline) { + this.__textBaseline = val; + this.commands[this.counter++] = TEXT_BASELINE; + this.commands[this.counter++] = -1; + this.commands[this.counter++] = this.getStrPtr(val); + } + + get textBaseline(): CanvasTextBaseline { + return this.__textBaseline; + } + + private __textAlign: CanvasTextAlign = 'center'; + + set textAlign(val: CanvasTextAlign) { + this.__textAlign = val; + this.commands[this.counter++] = TEXT_ALIGN; + this.commands[this.counter++] = -1; + this.commands[this.counter++] = this.getStrPtr(val); + } + + get textAlign(): CanvasTextAlign { + return this.__textAlign; + } + + rotate(val: number): void { + this.commands[this.counter++] = ROTATE; + this.commands[this.counter++] = 1; + this.commands[this.counter++] = val; + } + + private __direction: CanvasDirection = 'inherit'; + + set direction(val: CanvasDirection) { + this.__direction = val; + this.commands[this.counter++] = DIRECTION; + this.commands[this.counter++] = -1; + this.commands[this.counter++] = this.getStrPtr(val); + } + + get direction(): CanvasDirection { + return this.__direction; + } + + private __fontKerning: CanvasFontKerning = 'auto'; + + set fontKerning(val: CanvasFontKerning) { + this.__fontKerning = val; + this.commands[this.counter++] = FONT_KERNING; + this.commands[this.counter++] = -1; + this.commands[this.counter++] = this.getStrPtr(val); + } + + get fontKerning(): CanvasFontKerning { + return this.__fontKerning; + } + + private __lineJoin: CanvasLineJoin = 'miter'; + + set lineJoin(val: CanvasLineJoin) { + this.__lineJoin = val; + this.commands[this.counter++] = LINE_JOIN; + this.commands[this.counter++] = -1; + this.commands[this.counter++] = this.getStrPtr(val); + } + + get lineJoin(): CanvasLineJoin { + return this.__lineJoin; + } + + //#region unimplemented + globalAlpha = 0; + globalCompositeOperation = 'color' as const; + imageSmoothingEnabled = false; + imageSmoothingQuality = 'high' as const; + lineDashOffset = 0; + miterLimit = 0; + shadowBlur = 0; + shadowColor = ''; + shadowOffsetX = 0; + shadowOffsetY = 0; + filter = ''; + getContextAttributes(): CanvasRenderingContext2DSettings { + throw new Error('Method not implemented.'); + } + drawImage(image: CanvasImageSource, dx: number, dy: number): void; + drawImage(image: CanvasImageSource, dx: number, dy: number, dw: number, dh: number): void; + drawImage( + image: CanvasImageSource, + sx: number, + sy: number, + sw: number, + sh: number, + dx: number, + dy: number, + dw: number, + dh: number, + ): void; + drawImage( + image: unknown, + sx: unknown, + sy: unknown, + sw?: unknown, + sh?: unknown, + dx?: unknown, + dy?: unknown, + dw?: unknown, + dh?: unknown, + ): void; + drawImage(): void { + throw new Error('Method not implemented.'); + } + isPointInPath(x: number, y: number, fillRule?: CanvasFillRule | undefined): boolean; + isPointInPath(path: Path2D, x: number, y: number, fillRule?: CanvasFillRule | undefined): boolean; + isPointInPath(path: unknown, x: unknown, y?: unknown, fillRule?: unknown): boolean; + isPointInPath(): boolean { + throw new Error('Method not implemented.'); + } + isPointInStroke(x: number, y: number): boolean; + isPointInStroke(path: Path2D, x: number, y: number): boolean; + isPointInStroke(path: unknown, x: unknown, y?: unknown): boolean; + isPointInStroke(): boolean { + throw new Error('Method not implemented.'); + } + createConicGradient(startAngle: number, x: number, y: number): CanvasGradient; + createConicGradient(): CanvasGradient { + throw new Error('Method not implemented.'); + } + createPattern(image: CanvasImageSource, repetition: string | null): CanvasPattern | null; + createPattern(): CanvasPattern | null { + throw new Error('Method not implemented.'); + } + createRadialGradient(x0: number, y0: number, r0: number, x1: number, y1: number, r1: number): CanvasGradient; + createRadialGradient(): CanvasGradient { + throw new Error('Method not implemented.'); + } + createImageData(sw: number, sh: number, settings?: ImageDataSettings | undefined): ImageData; + createImageData(imagedata: ImageData): ImageData; + createImageData(sw: unknown, sh?: unknown, settings?: unknown): ImageData; + createImageData(): ImageData { + throw new Error('Method not implemented.'); + } + arcTo(x1: number, y1: number, x2: number, y2: number, radius: number): void; + arcTo(): void { + throw new Error('Method not implemented.'); + } + ellipse( + x: number, + y: number, + radiusX: number, + radiusY: number, + rotation: number, + startAngle: number, + endAngle: number, + counterclockwise?: boolean | undefined, + ): void; + ellipse(): void { + throw new Error('Method not implemented.'); + } + roundRect( + x: number, + y: number, + w: number, + h: number, + radii?: number | DOMPointInit | (number | DOMPointInit)[] | undefined, + ): void; + roundRect(): void { + throw new Error('Method not implemented.'); + } + getTransform(): DOMMatrix { + throw new Error('Method not implemented.'); + } + resetTransform(): void { + throw new Error('Method not implemented.'); + } + setTransform(a: number, b: number, c: number, d: number, e: number, f: number): void; + setTransform(transform?: DOMMatrix2DInit | undefined): void; + setTransform(): void { + throw new Error('Method not implemented.'); + } + transform(): void { + throw new Error('Method not implemented.'); + } + drawFocusIfNeeded(element: Element): void; + drawFocusIfNeeded(path: Path2D, element: Element): void; + drawFocusIfNeeded(): void { + throw new Error('Method not implemented.'); + } + reset(): void { + throw new Error('Method not implemented.'); + } + //#endregion +} + +export function isOffscreenCanvasModel(model: CanvasModel): model is CanvasModel { + return model.options.offscreen ?? false; +} \ No newline at end of file diff --git a/src/chart/canvas/offscreen/init-offscreen.ts b/src/chart/canvas/offscreen/init-offscreen.ts new file mode 100644 index 00000000..ac475fcc --- /dev/null +++ b/src/chart/canvas/offscreen/init-offscreen.ts @@ -0,0 +1,56 @@ +import { Remote, transfer, wrap } from 'comlink'; +import { CanvasModel } from '../../model/canvas.model'; +import { isOffscreenCanvasModel } from './canvas-offscreen-wrapper'; +import { OffscreenWorker } from './offscreen-worker'; +import { createMutex } from '../../utils/mutex'; +import { OffscreenFeature } from '../../chart.config'; + +export const isOffscreenWorkerAvailable = typeof Worker !== 'undefined' && typeof SharedArrayBuffer !== 'undefined'; + +if (!isOffscreenWorkerAvailable) { + console.warn('Offscreen worker is not available.'); +} + +const OffscreenWorkerClass = + isOffscreenWorkerAvailable && + wrap(new Worker(new URL('./offscreen-worker.js', import.meta.url))); + +// create global worker instance, so every chart will use the same worker +export let offscreenWorker: Remote; +// canvases idx offset is needed to avoid collisions between multiple charts canvases +let canvasesIdxOffset = 0; + +const mutex = createMutex(); + +export const initOffscreenWorker = async(canvases: CanvasModel[], fonts: OffscreenFeature['fonts']): Promise> => { + await mutex.calculateSafe(async() => { + if (offscreenWorker === undefined) { + if (typeof OffscreenWorkerClass === 'function') { + offscreenWorker = await new OffscreenWorkerClass(window.devicePixelRatio, fonts); + } else { + return Promise.reject('Offscreen worker is not available.'); + } + } + }); + const startOffset = canvasesIdxOffset; + canvasesIdxOffset += 100; + for (let i = 0; i < canvases.length; i++) { + const canvas = canvases[i]; + if (!isOffscreenCanvasModel(canvas)) { + continue; + } + // @ts-ignore + const offscreen = canvas.canvas.transferControlToOffscreen(); + const idx = startOffset + i; + canvas.idx = idx; + const buffer = await offscreenWorker.addCanvas( + idx, + canvas.options, + transfer(offscreen, [offscreen]), + canvas.options.offscreenBufferSize ?? 4000, + ); + canvas.ctx.initBuffer(buffer); + canvas.fireCanvasReady(); + } + return offscreenWorker; +}; \ No newline at end of file diff --git a/src/chart/canvas/offscreen/offscreen-worker.d.ts b/src/chart/canvas/offscreen/offscreen-worker.d.ts new file mode 100644 index 00000000..8cc2324f --- /dev/null +++ b/src/chart/canvas/offscreen/offscreen-worker.d.ts @@ -0,0 +1,39 @@ +import { OffscreenFeature } from "../../chart.config"; + +export declare class OffscreenWorker { + constructor(devicePixelRatio: number, fonts: OffscreenFeature['offscreenFonts']); + /** + * Adds offscreen canvas to the worker + */ + addCanvas( + idx: number, + options: CanvasRenderingContext2DSettings, + canvas: OffscreenCanvas, + sharedMemorySize: number, + ): SharedArrayBuffer; + + /** + * Loads font to the worker + */ + loadFont(fontFamily: string): void; + + /** + * Syncs an array of strings and their ids between main thread and worker thread + * the format of data in this array is [str1, id1, str2, id2, ...]. + * For detailed explanation @see CanvasOffscreenContext2D class + */ + syncStrings(strings: Array): void; + + /** + * Executes canvas commands for the given canvas ids + */ + executeCanvasCommands(canvasIds: number[]): void; + + /** + * Returns the color id for the given canvas idx and coordinates, @see HitTestCanvasModel + * @param idx canvas idx + * @param x - x coordinate on the canvas + * @param y - y coordinate on the canvas + */ + getColorId(idx: number, x: number, y: number): number; +} \ No newline at end of file diff --git a/src/chart/canvas/offscreen/offscreen-worker.js b/src/chart/canvas/offscreen/offscreen-worker.js new file mode 100644 index 00000000..38329e7a --- /dev/null +++ b/src/chart/canvas/offscreen/offscreen-worker.js @@ -0,0 +1,188 @@ +import { expose, proxy } from 'comlink'; +import { num2Ctx, END_OF_FILE } from './canvas-ctx.mapper'; + +/** + * Global pool of strings which is used to synchronize strings between main thread and worker thread. + * It's intentional that this map is global, because we want to have common strings pool between all charts. + * The goal of this pool is to have a unique string id for each string and write this id to the shared buffer, + * so we don't have to encode and write the string to the buffer (encoding string is not an easy task, + * also on repeated encodings it'll be much faster since we write only one number instead of array of bytes for corresponding string). + */ +const stringsPool = new Map(); +const STRINGS_BOUNDARY = -1_000_000; + +const bigPrimeNumber = 317; + +export class OffscreenWorker { + constructor(dpr, fonts) { + this.dpr = dpr; + this.ctxs = new Map(); + this.buffers = new Map(); + // Pre-allocate args arrays to avoid GC + this.args = [ + new Array(0), + new Array(1), + new Array(2), + new Array(3), + new Array(4), + new Array(5), + new Array(6), + new Array(7), + new Array(8), + new Array(9), + new Array(10), + ]; + return Promise.all(fonts.map(font => { + loadFont(font.fontFamily, font.url); + })).then(() => proxy(this)); + } + + defineCustomCanvasProperties(ctx) { + // reroute width and height setter to canvas + Object.defineProperty(ctx, 'width', { + set(width) { + ctx.canvas.width = width; + }, + }); + Object.defineProperty(ctx, 'height', { + set(height) { + ctx.canvas.height = height; + }, + }); + // define custom setLineDashFlat method, because we can't transfer objects like array directly using SharedArrayBuffer + Object.defineProperty(ctx, 'setLineDashFlat', { + value(...dash) { + ctx.setLineDash(dash); + }, + }); + Object.defineProperty(ctx, 'setGradientFillStyle', { + value(x, y, width, height, offset0, color0, offset1, color1) { + const gradient = ctx.createLinearGradient(x, y, width, height); + gradient.addColorStop(offset0, color0); + gradient.addColorStop(offset1, color1); + ctx.fillStyle = gradient; + }, + }); + const ctxs = this.ctxs; + const dpr = this.dpr; + Object.defineProperty(ctx, 'redrawBackgroundArea', { + value(backgroundCtxIdx, ctxIdx, x, y, width, height, opacity) { + const backgroundCtx = ctxs.get(backgroundCtxIdx); + const ctx = ctxs.get(ctxIdx); + ctx && backgroundCtx && redrawBackgroundArea(dpr, backgroundCtx, ctx, x, y, width, height, opacity); + }, + }); + } + + addCanvas(canvasIdx, options, canvas, bufferSize) { + const commandsBuffer = new SharedArrayBuffer(bufferSize); + this.buffers.set(canvasIdx, new Float32Array(commandsBuffer)); + const ctx = canvas.getContext('2d', options); + this.defineCustomCanvasProperties(ctx); + this.ctxs.set(canvasIdx, ctx); + return commandsBuffer; + } + + syncStrings(strs) { + for (let i = 0; i < strs.length; i += 2) { + stringsPool.set(strs[i + 1], strs[i]); + } + } + + executeCanvasCommands(canvasIds) { + for (const [canvasId, ctxCommands] of this.buffers.entries()) { + if (!canvasIds.includes(canvasId)) { + continue; + } + let counter = 0; + const ctx = this.ctxs.get(canvasId); + while (ctxCommands[counter] !== END_OF_FILE) { + const method = num2Ctx[ctxCommands[counter++]]; + const argsLen = ctxCommands[counter++]; + if (argsLen !== -1) { + const args = this.args[argsLen]; + for (let i = 0; i < argsLen; i++) { + const arg = ctxCommands[counter++]; + // simple heuristic to detect strings (@see CanvasOffscreenContext2D) + args[i] = arg < STRINGS_BOUNDARY ? stringsPool.get(arg) : arg; + } + ctx[method].apply(ctx, args); + } else { + const arg = ctxCommands[counter++]; + // simple heuristic to detect strings (@see CanvasOffscreenContext2D) + ctx[method] = arg < STRINGS_BOUNDARY ? stringsPool.get(arg) : arg; + } + } + } + } + + getColorId(idx, x, y) { + const ctx = this.ctxs.get(idx); + if (!ctx) { + return -1; + } + const data = ctx.getImageData(x * this.dpr, y * this.dpr, 1, 1).data; + const id = (data[0] * 65536 + data[1] * 256 + data[2]) / bigPrimeNumber; + return id; + } +} + +expose(OffscreenWorker); + +// eslint-disable-next-line no-bitwise +const floor = value => ~~value; +// redrawing background area is very expensive in offscreen for some reason, so disable it +const disableRedrawBackgroundArea = true; +// this function in used in case when +// some entity can overlap with another chart entity, so we need to hide the another entity +export const redrawBackgroundArea = (dpr, backgroundCtx, ctx, x, y, width, height, opacity) => { + if (disableRedrawBackgroundArea) { + return; + } + const xCoord = x * dpr; + const yCoord = y * dpr; + const widthCoord = width * dpr; + const heightCoord = height * dpr; + let imageData = backgroundCtx.getImageData(xCoord, yCoord, widthCoord, heightCoord); + if (opacity !== undefined) { + // convert rgba to rgb for black background + // Target.R = (Source.A * Source.R) + // Target.G = (Source.A * Source.G) + // Target.B = (Source.A * Source.B) + const alpha = imageData.data[3] / 255; + if (alpha === 1) { + // fast path + for (let i = 3; i < imageData.data.length; i += 4) { + imageData.data[i] = floor(imageData.data[i] * opacity); + } + } else { + for (let i = 0; i < imageData.data.length; i++) { + const v = imageData.data[i]; + imageData.data[i] = i % 4 === 3 ? floor(v * opacity) : floor(alpha * v); + } + } + imageData = new ImageData( + // i % 4 === 3 - this condition is for alpha channel + imageData.data, + imageData.width, + imageData.height, + { colorSpace: imageData.colorSpace }, + ); + } + ctx.putImageData(imageData, xCoord, yCoord); +}; + + +const loadFont = (fontName, url) => { + if (self.FontFace) { + // first declare our font-face + const fontFace = new FontFace( + fontName, + url, + ); + // add it to the list of fonts our worker supports + self.fonts.add(fontFace); + return fontFace.load(); + } + return Promise.reject(); +}; \ No newline at end of file diff --git a/src/chart/canvas/y-axis-bounds.container.ts b/src/chart/canvas/y-axis-bounds.container.ts index 1e714ef5..0b723972 100644 --- a/src/chart/canvas/y-axis-bounds.container.ts +++ b/src/chart/canvas/y-axis-bounds.container.ts @@ -3,8 +3,7 @@ * This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. * If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { YAxisConfig, FullChartConfig, getFontFromConfig } from '../chart.config'; -import { CanvasModel } from '../model/canvas.model'; +import { FullChartConfig, YAxisConfig, getFontFromConfig } from '../chart.config'; import { calculateTextWidth } from '../utils/canvas/canvas-font-measure-tool.utils'; export interface YAxisWidthContributor { @@ -29,7 +28,7 @@ export interface YAxisWidths { export class YAxisBoundsContainer { public extentsOrder: ExtentsOrder = new Map(); - constructor(private config: FullChartConfig, private mainCanvasModel: CanvasModel) {} + constructor(private config: FullChartConfig) {} yAxisWidthContributors: YAxisWidthContributor[] = []; /** @@ -59,7 +58,7 @@ export class YAxisBoundsContainer { */ private getTextWidth(text: string): number { const font = getFontFromConfig(this.config.components.yAxis); - return calculateTextWidth(text, this.mainCanvasModel.ctx, font); + return calculateTextWidth(text, font); } /** diff --git a/src/chart/chart.config.ts b/src/chart/chart.config.ts index 1faef192..53778f2e 100644 --- a/src/chart/chart.config.ts +++ b/src/chart/chart.config.ts @@ -68,6 +68,21 @@ export const getDefaultConfig = (): FullChartConfig => ({ shortDays: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], shortMonths: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'], rtl: false, + experimental: { + offscreen: { + enabled: false, + fonts: [], + bufferSizes: { + mainCanvas: 50_000, + hitTestCanvas: 15 * 50_000, + dynamicObjectsCanvas: 16 * 100_000, + yAxisLabelsCanvas: 50_000, + crossToolCanvas: 8000, + backgroundCanvas: 4000, + snapshotCanvas: 12 * 100_000, + } + }, + }, scale: { keepZoomXOnYAxisChange: true, auto: true, @@ -858,6 +873,34 @@ export interface FullChartConfig extends TimeFormatterConfig { useUTCTimeOverride: boolean; animation: AnimationConfig; devexpertsPromoLink: boolean; + experimental: ExperimentalFeatures; +} + +export interface ExperimentalFeatures { + offscreen: OffscreenFeature; +} + +export interface OffscreenFeature { + /** + * Enables chart's drawing in the offscreen canvas worker. + */ + enabled: boolean; + // if you use custom fonts for canvas objects, then you need to provide them here + fonts: Array<{ + // example: 'Open Sans Semibold' + fontFamily: string; + // example: url('../fonts/OpenSans-Bold.ttf'), url('../fonts/OpenSans-Bold.ttf') format('truetype') + url: string; + }>; + bufferSizes: { + mainCanvas: number; + snapshotCanvas: number; + crossToolCanvas: number; + dynamicObjectsCanvas: number; + backgroundCanvas: number; + hitTestCanvas: number; + yAxisLabelsCanvas: number; + }; } // use this to merge partial config with existing diff --git a/src/chart/components/cross_tool/cross-tool.drawer.ts b/src/chart/components/cross_tool/cross-tool.drawer.ts index 1a5bede9..5b8a37f5 100644 --- a/src/chart/components/cross_tool/cross-tool.drawer.ts +++ b/src/chart/components/cross_tool/cross-tool.drawer.ts @@ -8,7 +8,7 @@ import { Drawer } from '../../drawers/drawing-manager'; import { CrossToolHover, CrossToolModel, CrossToolType } from './cross-tool.model'; export interface CrossToolTypeDrawer { - draw: (ctx: CanvasRenderingContext2D, hover: CrossToolHover) => void; + draw: (canvasModel: CanvasModel, hover: CrossToolHover) => void; } export class CrossToolDrawer implements Drawer { @@ -29,7 +29,7 @@ export class CrossToolDrawer implements Drawer { draw() { const drawer = this.crossToolTypeDrawers[this.model.type]; if (drawer) { - this.model.currentHover && drawer.draw(this.crossToolCanvasModel.ctx, this.model.currentHover); + this.model.currentHover && drawer.draw(this.crossToolCanvasModel, this.model.currentHover); } else { console.error(`No cross tool drawer type registered for drawer type ${this.model.type}`); } diff --git a/src/chart/components/cross_tool/types/cross-and-labels.drawer.ts b/src/chart/components/cross_tool/types/cross-and-labels.drawer.ts index 25fb9a06..523bcce1 100644 --- a/src/chart/components/cross_tool/types/cross-and-labels.drawer.ts +++ b/src/chart/components/cross_tool/types/cross-and-labels.drawer.ts @@ -5,6 +5,7 @@ */ import { CanvasBoundsContainer, CanvasElement } from '../../../canvas/canvas-bounds-container'; import { FullChartConfig } from '../../../chart.config'; +import { CanvasModel } from '../../../model/canvas.model'; import { avoidAntialiasing, drawRoundedRect } from '../../../utils/canvas/canvas-drawing-functions.utils'; import { PaneManager } from '../../pane/pane-manager.component'; import { priceLabelDrawersMap } from '../../y_axis/price_labels/price-label.drawer'; @@ -30,9 +31,9 @@ export class CrossAndLabelsDrawerType implements CrossToolTypeDrawer { * @param {CanvasRenderingContext2D} ctx - The canvas context to draw on. * @param {CrossToolHover} hover - The hover object containing information about the cross tool's position. */ - draw(ctx: CanvasRenderingContext2D, hover: CrossToolHover) { + draw(canvasModel: CanvasModel, hover: CrossToolHover) { if (this.crossDrawPredicate()) { - avoidAntialiasing(ctx, () => this.drawCrossTool(ctx, hover)); + avoidAntialiasing(canvasModel.ctx, () => this.drawCrossTool(canvasModel, hover)); } } @@ -41,7 +42,7 @@ export class CrossAndLabelsDrawerType implements CrossToolTypeDrawer { * The method draws a cross tool on the canvas using the provided context. It first gets the bounds of all panes and the hit test bounds of all panes. It then gets the top padding of the x-axis from the configuration and the left padding of the y-axis from the configuration based on the type of y-label. * If the hit test bounds of all panes contain the hover coordinates, it draws a horizontal line and a vertical line using the provided coordinates and the bounds of all panes. It sets the stroke style to the line color from the configuration and sets the line dash to the line dash from the configuration. It then begins a new path, moves to the start of the horizontal line, draws a line to the end of the horizontal line, moves to the start of the vertical line, and draws a line to the end of the vertical line. Finally, it strokes the path. */ - protected drawCrossTool(ctx: CanvasRenderingContext2D, hover: CrossToolHover) { + protected drawCrossTool(canvasModel: CanvasModel, hover: CrossToolHover) { const allPanes = this.canvasBoundsContainer.getBounds(CanvasElement.ALL_PANES); // extension is need for allPanes.y to fit into hit test const allPanesHT = this.canvasBoundsContainer.getBoundsHitTest(CanvasElement.ALL_PANES, { extensionY: 0.0001 }); @@ -63,6 +64,7 @@ export class CrossAndLabelsDrawerType implements CrossToolTypeDrawer { }; const allowHorizontal = paneHT?.(hover.x, hover.y); + const ctx = canvasModel.ctx; if (!this.noLines) { ctx.strokeStyle = this.config.colors.crossTool.lineColor; ctx.setLineDash(this.config.components.crossTool.lineDash); @@ -77,7 +79,7 @@ export class CrossAndLabelsDrawerType implements CrossToolTypeDrawer { ctx.stroke(); } - allowHorizontal && this.drawYLabel(ctx, hover); + allowHorizontal && this.drawYLabel(canvasModel, hover); this.drawXLabel(ctx, hover); } } @@ -129,7 +131,7 @@ export class CrossAndLabelsDrawerType implements CrossToolTypeDrawer { * @param {Point} point - The point where the label should be drawn. * @returns {void} */ - protected drawYLabel(ctx: CanvasRenderingContext2D, point: CrossToolHover) { + protected drawYLabel(canvasModel: CanvasModel, point: CrossToolHover) { const yLabelPadding = this.config.components.crossTool.yLabel.padding; const crossToolColors = this.config.colors.crossTool; // Y axis label different for main chart pane and the rest panes @@ -149,7 +151,7 @@ export class CrossAndLabelsDrawerType implements CrossToolTypeDrawer { const drawYLabel = priceLabelDrawersMap[type]; const textColor = type === 'plain' ? crossToolColors.lineColor : crossToolColors.labelTextColor; drawYLabel( - ctx, + canvasModel, bounds, label, y, diff --git a/src/chart/components/events/events-hit-test.drawer.ts b/src/chart/components/events/events-hit-test.drawer.ts index 9fcbf53b..74925989 100644 --- a/src/chart/components/events/events-hit-test.drawer.ts +++ b/src/chart/components/events/events-hit-test.drawer.ts @@ -4,7 +4,7 @@ * If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import { Drawer } from '../../drawers/drawing-manager'; -import { HitTestCanvasModel } from '../../model/hit-test-canvas.model'; +import { HitTestCanvasModel, idToColor } from '../../model/hit-test-canvas.model'; import { ChartModel } from '../chart/chart.model'; import { FullChartConfig } from '../../chart.config'; import { CanvasBoundsContainer, CanvasElement } from '../../canvas/canvas-bounds-container'; @@ -49,7 +49,7 @@ export class EventsHitTestDrawer implements Drawer { ctx.fillStyle = color; const size = getEventSize(event); // draw hit test - ctx.fillStyle = this.hitTestCanvasModel.idToColor(event.id); + ctx.fillStyle = idToColor(event.id); const hoverSize = (size + hoverExtendedAreaPixels) * 2; if (prevX !== undefined) { const prevSize = getEventSize(prevEvent); diff --git a/src/chart/components/highlights/highlights.drawer.ts b/src/chart/components/highlights/highlights.drawer.ts index f159f316..c0d06f59 100644 --- a/src/chart/components/highlights/highlights.drawer.ts +++ b/src/chart/components/highlights/highlights.drawer.ts @@ -92,7 +92,7 @@ export class HighlightsDrawer implements Drawer { const itemColors = this.config.colors.highlights[item.type]; ctx.save(); ctx.fillStyle = itemColors?.label ?? '#ffffff'; - const labelWidth = calculateTextWidth(label, ctx, font); + const labelWidth = calculateTextWidth(label, font); const [labelX, labelY] = this.resolveHighlightLabelPosition( item.label.placement ?? 'left-left', chartBounds, diff --git a/src/chart/components/navigation_map/navigation-map.drawer.ts b/src/chart/components/navigation_map/navigation-map.drawer.ts index 023a3a31..74f4d718 100644 --- a/src/chart/components/navigation_map/navigation-map.drawer.ts +++ b/src/chart/components/navigation_map/navigation-map.drawer.ts @@ -19,6 +19,7 @@ import { import { DateTimeFormatterFactory } from '../../model/date-time.formatter'; import { getFormattedTimeLabel } from './navigation-map.model'; import { flat } from '../../utils/array.utils'; +import { isOffscreenCanvasModel } from '../../canvas/offscreen/canvas-offscreen-wrapper'; const BTN_ARROW_WIDTH = 4; @@ -97,10 +98,25 @@ export class NavigationMapDrawer implements Drawer { this.config.colors.navigationMap.mapGradientTopColor && this.config.colors.navigationMap.mapGradientBottomColor ) { - const grd = ctx.createLinearGradient(chart.x, chart.y, chart.x, chart.y + chart.height); - grd.addColorStop(0, this.config.colors.navigationMap.mapGradientTopColor); - grd.addColorStop(1, this.config.colors.navigationMap.mapGradientBottomColor); - ctx.fillStyle = grd; + if (isOffscreenCanvasModel(this.canvasModel)) { + const offscreenCtx = this.canvasModel.ctx; + // special method for gradient fill, because we can't transfer CanvasGradient directly to offscreen + offscreenCtx.setGradientFillStyle( + chart.x, + chart.y, + chart.x, + chart.y + chart.height, + 0, + this.config.colors.navigationMap.mapGradientTopColor, + 1, + this.config.colors.navigationMap.mapGradientBottomColor, + ); + } else { + const grd = ctx.createLinearGradient(chart.x, chart.y, chart.x, chart.y + chart.height); + grd.addColorStop(0, this.config.colors.navigationMap.mapGradientTopColor); + grd.addColorStop(1, this.config.colors.navigationMap.mapGradientBottomColor); + ctx.fillStyle = grd; + } } ctx.fill(); if (this.config.colors.navigationMap.mapColor) { diff --git a/src/chart/components/x_axis/time/x-axis-weights.functions.ts b/src/chart/components/x_axis/time/x-axis-weights.functions.ts index 0b7f9a49..46fae406 100644 --- a/src/chart/components/x_axis/time/x-axis-weights.functions.ts +++ b/src/chart/components/x_axis/time/x-axis-weights.functions.ts @@ -171,8 +171,8 @@ export const overlappingPredicate = ( overlapingDistance: number, ): boolean => { const font = XAxisTimeLabelsDrawer.getFontFromConfig(config); - const currentLabelTextWidth = calculateTextWidth(label.text, ctx, font); - const nextLabelTextWidth = calculateTextWidth(nextLabel.text, ctx, font); + const currentLabelTextWidth = calculateTextWidth(label.text, font); + const nextLabelTextWidth = calculateTextWidth(nextLabel.text, font); const curPx = scale.toX(label.value) + currentLabelTextWidth / 2; const nextPx = scale.toX(nextLabel.value) - nextLabelTextWidth / 2; return nextPx - curPx < overlapingDistance; diff --git a/src/chart/components/x_axis/x-axis-draw.functions.ts b/src/chart/components/x_axis/x-axis-draw.functions.ts index 77ec480a..68efc1a6 100644 --- a/src/chart/components/x_axis/x-axis-draw.functions.ts +++ b/src/chart/components/x_axis/x-axis-draw.functions.ts @@ -5,6 +5,7 @@ */ import { CanvasBoundsContainer, CanvasElement } from '../../canvas/canvas-bounds-container'; import { FullChartConfig } from '../../chart.config'; +import { CanvasModel } from '../../model/canvas.model'; import { XAxisLabel } from './x-axis-labels.model'; const DEFAULT_X_LABEL_PADDING = { x: 4, y: 4 }; @@ -19,7 +20,7 @@ export type LabelAlign = 'start' | 'end' | 'middle'; * @param label */ export function drawXAxisLabel( - ctx: CanvasRenderingContext2D, + canvasModel: CanvasModel, canvasBoundsContainer: CanvasBoundsContainer, config: FullChartConfig, label: XAxisLabel, @@ -29,6 +30,7 @@ export function drawXAxisLabel( const xAxisColors = config.colors.xAxis; const offsetTop = padding.top ?? 0; + const ctx = canvasModel.ctx; const xAxisBounds = canvasBoundsContainer.getBounds(CanvasElement.X_AXIS); ctx.save(); ctx.font = `bold ${fontSize}px ${fontFamily}`; diff --git a/src/chart/components/x_axis/x-axis-labels.drawer.ts b/src/chart/components/x_axis/x-axis-labels.drawer.ts index ae718304..e7f441a0 100644 --- a/src/chart/components/x_axis/x-axis-labels.drawer.ts +++ b/src/chart/components/x_axis/x-axis-labels.drawer.ts @@ -32,10 +32,9 @@ export class XAxisLabelsDrawer implements Drawer { * @returns {void} */ draw() { - const ctx = this.canvasModel.ctx; this.drawHighlightedBackgroundBetweenLabels(); this.xAxisLabelsModel.labels.forEach(l => { - drawXAxisLabel(ctx, this.canvasBoundsContainer, this.config, l); + drawXAxisLabel(this.canvasModel, this.canvasBoundsContainer, this.config, l); }); } diff --git a/src/chart/components/x_axis/x-axis-time-labels.drawer.ts b/src/chart/components/x_axis/x-axis-time-labels.drawer.ts index ac2ffc6b..dabefa96 100644 --- a/src/chart/components/x_axis/x-axis-time-labels.drawer.ts +++ b/src/chart/components/x_axis/x-axis-time-labels.drawer.ts @@ -74,7 +74,7 @@ export class XAxisTimeLabelsDrawer implements Drawer { const font = `${fontHeight}px ${fontFamily}`; ctx.fillStyle = color; for (const label of labels) { - const x = this.viewportModel.toX(label.value) - calculateTextWidth(label.text, ctx, font) / 2; + const x = this.viewportModel.toX(label.value) - calculateTextWidth(label.text, font) / 2; const y = bounds.y + fontHeight - 1 + offsetTop; // -1 for font drawing inconsistency const labelText = label.text; ctx.font = font; diff --git a/src/chart/components/y_axis/price_labels/price-label.drawer.ts b/src/chart/components/y_axis/price_labels/price-label.drawer.ts index 01f7a2b2..f96a0259 100644 --- a/src/chart/components/y_axis/price_labels/price-label.drawer.ts +++ b/src/chart/components/y_axis/price_labels/price-label.drawer.ts @@ -4,6 +4,7 @@ * If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import { CanvasBoundsContainer, CanvasElement, CHART_UUID } from '../../../canvas/canvas-bounds-container'; +import { isOffscreenCanvasModel } from '../../../canvas/offscreen/canvas-offscreen-wrapper'; import { YAxisConfig, FullChartColors, @@ -14,6 +15,7 @@ import { } from '../../../chart.config'; import { redrawBackgroundArea } from '../../../drawers/chart-background.drawer'; import { Bounds } from '../../../model/bounds.model'; +import { CanvasModel } from '../../../model/canvas.model'; import { avoidAntialiasing, drawLine } from '../../../utils/canvas/canvas-drawing-functions.utils'; import { calculateSymbolHeight, calculateTextWidth } from '../../../utils/canvas/canvas-font-measure-tool.utils'; import { floor } from '../../../utils/math.utils'; @@ -41,8 +43,8 @@ export const priceLabelDrawersMap: Record = { * @param config */ export function drawLabel( - ctx: CanvasRenderingContext2D, - backgroundCtx: CanvasRenderingContext2D, + canvasModel: CanvasModel, + backgroundCanvasModel: CanvasModel, bounds: Bounds, paneBounds: Bounds, visualLabel: VisualYAxisLabel, @@ -50,6 +52,7 @@ export function drawLabel( config: YAxisConfig, colors: FullChartColors, ) { + const ctx = canvasModel.ctx; const centralY = visualLabel.y; const text = visualLabel.labelText; const mode = visualLabel.mode ?? 'label'; @@ -59,9 +62,9 @@ export function drawLabel( const textFont = visualLabel.textFont ?? getFontFromConfig(config); const bgColor = visualLabel.bgColor; const lineColor = visualLabel.lineColor ?? bgColor; - const descriptionWidth = calculateTextWidth(description ?? '', ctx, textFont) + 8; + const descriptionWidth = calculateTextWidth(description ?? '', textFont) + 8; const labelY = floor(visualLabel.y); - const fontHeight = calculateSymbolHeight(textFont, ctx); + const fontHeight = calculateSymbolHeight(textFont); const labelBoxTopY = centralY - fontHeight / 2; const labelBoxBottomY = centralY + fontHeight / 2; const labelBoxHeight = labelBoxBottomY - labelBoxTopY; @@ -77,7 +80,7 @@ export function drawLabel( const showLine = isLineVisible(bounds, labelY, labelBoxHeight); const _drawDescription = () => - showDescription && drawDescription(backgroundCtx, ctx, bounds, paneBounds, visualLabel, config); + showDescription && drawDescription(backgroundCanvasModel, canvasModel, bounds, paneBounds, visualLabel, config); let lineXStart: number; let lineXEnd: number; @@ -95,7 +98,7 @@ export function drawLabel( const lineY = visualLabel.lineY ?? visualLabel.y; const _drawLine = () => showLine && avoidAntialiasing(ctx, () => drawLine(ctx, lineXStart, lineY, lineXEnd, lineY, 1)); - const _drawLabel = () => drawLabel(ctx, bounds, text, centralY, visualLabel, config, colors.yAxis, false); + const _drawLabel = () => drawLabel(canvasModel, bounds, text, centralY, visualLabel, config, colors.yAxis, false); const drawLineLabel = () => { _drawLine(); @@ -130,8 +133,8 @@ const isLineVisible = (bounds: Bounds, labelY: number, labelBoxHeight: number) = labelY > bounds.y + labelBoxHeight / 2 && labelY < bounds.y + bounds.height - labelBoxHeight / 2; function drawDescription( - backgroundCtx: CanvasRenderingContext2D, - ctx: CanvasRenderingContext2D, + backgroundCanvasModel: CanvasModel, + canvasModel: CanvasModel, labelBounds: Bounds, paneBounds: Bounds, visualLabel: VisualYAxisLabel, @@ -142,10 +145,12 @@ function drawDescription( if (!description || description.length === 0) { return; } + const ctx = canvasModel.ctx; + const backgroundCtx = backgroundCanvasModel.ctx; const centralY = visualLabel.y; const textFont = getFontFromConfig(yAxisState); - const descriptionWidth = calculateTextWidth(description, ctx, textFont); - const fontHeight = calculateSymbolHeight(textFont, ctx); + const descriptionWidth = calculateTextWidth(description, textFont); + const fontHeight = calculateSymbolHeight(textFont); const paddingTop = visualLabel.paddingTop ?? DEFAULT_PADDING; const paddingBottom = visualLabel.paddingBottom ?? DEFAULT_PADDING; const labelBoxY = centralY - fontHeight / 2 - paddingTop; @@ -161,7 +166,19 @@ function drawDescription( const boundsEnd = paneBounds.x + paneBounds.width; const x = align === 'right' ? boundsEnd - rectWidth : paneBounds.x + descriptionPadding; - redrawBackgroundArea(backgroundCtx, ctx, x, labelBoxY, width, labelBoxHeight, 0.8); + if (isOffscreenCanvasModel(canvasModel)) { + canvasModel.ctx.redrawBackgroundArea( + backgroundCanvasModel.idx, + canvasModel.idx, + x, + labelBoxY, + width, + labelBoxHeight, + 0.8, + ); + } else { + redrawBackgroundArea(backgroundCtx, ctx, x, labelBoxY, width, labelBoxHeight, 0.8); + } ctx.fillStyle = visualLabel.descColor ?? visualLabel.bgColor; ctx.font = textFont; diff --git a/src/chart/components/y_axis/price_labels/y-axis-price-labels.drawer.ts b/src/chart/components/y_axis/price_labels/y-axis-price-labels.drawer.ts index 49cc898c..4d31b51f 100644 --- a/src/chart/components/y_axis/price_labels/y-axis-price-labels.drawer.ts +++ b/src/chart/components/y_axis/price_labels/y-axis-price-labels.drawer.ts @@ -27,9 +27,6 @@ export class YAxisPriceLabelsDrawer implements Drawer { ) {} draw() { - const ctx = this.yAxisLabelsCanvasModel.ctx; - const backgroundCtx = this.backgroundCanvasModel.ctx; - this.paneManager.yExtents.forEach(extent => { if (extent.yAxis.state.visible) { const yAxisBounds = extent.getYAxisBounds(); @@ -40,8 +37,8 @@ export class YAxisPriceLabelsDrawer implements Drawer { const bounds = l.bounds ?? yAxisBounds; l.labels.forEach(vl => drawLabel( - ctx, - backgroundCtx, + this.yAxisLabelsCanvasModel, + this.backgroundCanvasModel, bounds, paneBounds, vl, @@ -54,8 +51,8 @@ export class YAxisPriceLabelsDrawer implements Drawer { // TODO I added this as a simple mechanism to add custom labels, we need to review it Object.values(extent.yAxis.model.fancyLabelsModel.customLabels).forEach(l => drawLabel( - ctx, - backgroundCtx, + this.yAxisLabelsCanvasModel, + this.backgroundCanvasModel, yAxisBounds, paneBounds, l, diff --git a/src/chart/components/y_axis/y-axis-labels.drawer.ts b/src/chart/components/y_axis/y-axis-labels.drawer.ts index 3d649182..d9ac9b93 100644 --- a/src/chart/components/y_axis/y-axis-labels.drawer.ts +++ b/src/chart/components/y_axis/y-axis-labels.drawer.ts @@ -3,8 +3,9 @@ * This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. * If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { YAxisConfig, FullChartColors, getFontFromConfig } from '../../chart.config'; +import { FullChartColors, YAxisConfig, getFontFromConfig } from '../../chart.config'; import { Bounds } from '../../model/bounds.model'; +import { CanvasModel } from '../../model/canvas.model'; import { drawPriceLabel, drawRoundedRect } from '../../utils/canvas/canvas-drawing-functions.utils'; import { calculateSymbolHeight, calculateTextWidth } from '../../utils/canvas/canvas-font-measure-tool.utils'; import { getLabelTextColorByBackgroundColor } from '../../utils/canvas/canvas-text-functions.utils'; @@ -47,7 +48,7 @@ export const DEFAULT_PRICE_LABEL_PADDING = 4; * @param checkBoundaries */ export function drawBadgeLabel( - ctx: CanvasRenderingContext2D, + canvasModel: CanvasModel, bounds: Bounds, text: string, centralY: number, @@ -56,6 +57,7 @@ export function drawBadgeLabel( yAxisColors: FullChartColors['yAxis'], checkBoundaries: boolean = true, ): void { + const ctx = canvasModel.ctx; const align = yAxisState.align; const textFont = config.textFont ?? getFontFromConfig(yAxisState); const bgColor = config.bgColor; @@ -65,7 +67,7 @@ export function drawBadgeLabel( const paddingTop = config.paddingTop ?? DEFAULT_PRICE_LABEL_PADDING; const paddingBottom = config.paddingBottom ?? DEFAULT_PRICE_LABEL_PADDING; const paddingEnd = config.paddingEnd ?? DEFAULT_PRICE_LABEL_PADDING; - const halfFontHeight = round(calculateSymbolHeight(textFont, ctx) / 2); + const halfFontHeight = round(calculateSymbolHeight(textFont) / 2); const labelBoxTopY = centralY - halfFontHeight - paddingTop; const labelBoxBottomY = centralY + halfFontHeight + paddingBottom; const labelBoxHeight = labelBoxBottomY - labelBoxTopY; @@ -104,7 +106,7 @@ export function drawBadgeLabel( ctx.font = textFont; const textX = align === 'right' - ? bounds.x + bounds.width - calculateTextWidth(text, ctx, textFont) - xTextOffset + ? bounds.x + bounds.width - calculateTextWidth(text, textFont) - xTextOffset : bounds.x + xTextOffset; ctx.fillText(text, textX, centralY + halfFontHeight - 1); // -1 for font height adjustment ctx.restore(); @@ -122,7 +124,7 @@ export function drawBadgeLabel( * @param checkBoundaries */ export function drawRectLabel( - ctx: CanvasRenderingContext2D, + canvasModel: CanvasModel, bounds: Bounds, text: string, centralY: number, @@ -131,6 +133,7 @@ export function drawRectLabel( yAxisColors: FullChartColors['yAxis'], checkBoundaries: boolean = true, ) { + const ctx = canvasModel.ctx; const align = yAxisState.align; const textFont = config.textFont ?? getFontFromConfig(yAxisState); const bgColor = config.bgColor; @@ -142,7 +145,7 @@ export function drawRectLabel( const paddingBottom = config.paddingBottom ?? paddings?.bottom ?? DEFAULT_PRICE_LABEL_PADDING; const paddingEnd = config.paddingEnd ?? paddings?.end ?? DEFAULT_PRICE_LABEL_PADDING; const paddingStart = config.paddingStart; - const fontHeight = calculateSymbolHeight(textFont, ctx); + const fontHeight = calculateSymbolHeight(textFont); const labelBoxTopY = centralY - fontHeight / 2 - paddingTop; const labelBoxBottomY = centralY + fontHeight / 2 + paddingBottom; const labelBoxHeight = labelBoxBottomY - labelBoxTopY; @@ -159,7 +162,7 @@ export function drawRectLabel( const xTextOffset = yAxisState.labelBoxMargin.end; ctx.font = textFont; - const textWidth = calculateTextWidth(text, ctx, textFont); + const textWidth = calculateTextWidth(text, textFont); const marginEnd = xTextOffset - paddingEnd; const width = paddingStart !== undefined ? textWidth + paddingStart + paddingEnd : bounds.width - marginEnd; @@ -190,7 +193,7 @@ export function drawRectLabel( * @param checkBoundaries */ export function drawPlainLabel( - ctx: CanvasRenderingContext2D, + canvasModel: CanvasModel, bounds: Bounds, text: string, centralY: number, @@ -199,6 +202,8 @@ export function drawPlainLabel( yAxisColors: FullChartColors['yAxis'], checkBoundaries: boolean = true, ) { + const ctx = canvasModel.ctx; + const align = yAxisState.align; const textFont = config.textFont ?? getFontFromConfig(yAxisState); const bgColor = yAxisColors.backgroundColor; @@ -210,7 +215,7 @@ export function drawPlainLabel( const paddingBottom = config.paddingBottom ?? paddings?.bottom ?? DEFAULT_PRICE_LABEL_PADDING; const paddingEnd = config.paddingEnd ?? paddings?.end ?? DEFAULT_PRICE_LABEL_PADDING; const paddingStart = config.paddingStart; - const fontHeight = calculateSymbolHeight(textFont, ctx); + const fontHeight = calculateSymbolHeight(textFont); const labelBoxTopY = centralY - fontHeight / 2 - paddingTop; const labelBoxBottomY = centralY + fontHeight / 2 + paddingBottom; const labelBoxHeight = labelBoxBottomY - labelBoxTopY; @@ -226,7 +231,7 @@ export function drawPlainLabel( const xTextOffset = yAxisState.labelBoxMargin.end; ctx.font = textFont; - const textWidth = calculateTextWidth(text, ctx, textFont); + const textWidth = calculateTextWidth(text, textFont); const marginEnd = xTextOffset - paddingEnd; const width = paddingStart !== undefined ? textWidth + paddingStart + paddingEnd : bounds.width - marginEnd; const x = align === 'right' ? bounds.x + bounds.width - marginEnd - width : bounds.x + marginEnd; @@ -254,7 +259,7 @@ export function getLabelYOffset( ctx: CanvasRenderingContext2D, paddingTop: number = DEFAULT_PRICE_LABEL_PADDING, ) { - const fontHeight = calculateSymbolHeight(font, ctx); + const fontHeight = calculateSymbolHeight(font); return fontHeight / 2 + paddingTop; } diff --git a/src/chart/components/y_axis/y-axis.drawer.ts b/src/chart/components/y_axis/y-axis.drawer.ts index a5e8554d..29f6c5af 100644 --- a/src/chart/components/y_axis/y-axis.drawer.ts +++ b/src/chart/components/y_axis/y-axis.drawer.ts @@ -56,7 +56,7 @@ export class YAxisDrawer implements Drawer { ctx.fillRect(bounds.x, bounds.y, bounds.width, bounds.height); const font = getFontFromConfig(yAxisComponent.state); - const fontHeight = calculateSymbolHeight(font, ctx); + const fontHeight = calculateSymbolHeight(font); const textColor = this.getLabelTextColor(); ctx.save(); @@ -150,7 +150,7 @@ const drawSimpleLabel = ( ) => { const xTextBounds = yAxisAlign === 'right' - ? bounds.x + bounds.width - calculateTextWidth(text, ctx, font) - padding + ? bounds.x + bounds.width - calculateTextWidth(text, font) - padding : bounds.x + padding; ctx.fillText(text, xTextBounds, centralY + fontHeight / 2 - 1); // -1 for font height adjustment }; diff --git a/src/chart/drawers/chart-background.drawer.ts b/src/chart/drawers/chart-background.drawer.ts index f372136f..70605f91 100644 --- a/src/chart/drawers/chart-background.drawer.ts +++ b/src/chart/drawers/chart-background.drawer.ts @@ -3,12 +3,13 @@ * This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. * If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { Drawer } from './drawing-manager'; -import { CanvasModel } from '../model/canvas.model'; +import { isOffscreenCanvasModel } from '../canvas/offscreen/canvas-offscreen-wrapper'; import { ChartAreaTheme, FullChartConfig } from '../chart.config'; +import { CanvasModel } from '../model/canvas.model'; import { getDPR } from '../utils/device/device-pixel-ratio.utils'; import { floor } from '../utils/math.utils'; import { deepEqual } from '../utils/object.utils'; +import { Drawer } from './drawing-manager'; export class BackgroundDrawer implements Drawer { constructor( @@ -25,10 +26,30 @@ export class BackgroundDrawer implements Drawer { this.canvasModel.clear(); const ctx = this.canvasModel.ctx; if (this.config.colors.chartAreaTheme.backgroundMode === 'gradient') { - const grd = ctx.createLinearGradient(0, 0 + this.canvasModel.height / 2, this.canvasModel.width, 0 + this.canvasModel.height / 2); - grd.addColorStop(0, this.config.colors.chartAreaTheme.backgroundGradientTopColor); - grd.addColorStop(1, this.config.colors.chartAreaTheme.backgroundGradientBottomColor); - ctx.fillStyle = grd; + if (isOffscreenCanvasModel(this.canvasModel)) { + const offscreenCtx = this.canvasModel.ctx; + // special method for gradient fill, because we can't transfer CanvasGradient directly to offscreen + offscreenCtx.setGradientFillStyle( + 0, + 0 + this.canvasModel.height / 2, + this.canvasModel.width, + 0 + this.canvasModel.height / 2, + 0, + this.config.colors.chartAreaTheme.backgroundGradientTopColor, + 1, + this.config.colors.chartAreaTheme.backgroundGradientBottomColor, + ); + } else { + const grd = ctx.createLinearGradient( + 0, + 0 + this.canvasModel.height / 2, + this.canvasModel.width, + 0 + this.canvasModel.height / 2, + ); + grd.addColorStop(0, this.config.colors.chartAreaTheme.backgroundGradientTopColor); + grd.addColorStop(1, this.config.colors.chartAreaTheme.backgroundGradientBottomColor); + ctx.fillStyle = grd; + } } else { ctx.fillStyle = this.config.colors.chartAreaTheme.backgroundColor; } diff --git a/src/chart/drawers/chart-type-drawers/area.drawer.ts b/src/chart/drawers/chart-type-drawers/area.drawer.ts index bdb76812..6d78a4ed 100644 --- a/src/chart/drawers/chart-type-drawers/area.drawer.ts +++ b/src/chart/drawers/chart-type-drawers/area.drawer.ts @@ -3,8 +3,10 @@ * This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. * If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { isOffscreenCanvasModel } from '../../canvas/offscreen/canvas-offscreen-wrapper'; import { ChartConfigComponentsChart } from '../../chart.config'; import { CandleSeriesModel } from '../../model/candle-series.model'; +import { CanvasModel } from '../../model/canvas.model'; import { DataSeriesModel, VisualSeriesPoint } from '../../model/data-series.model'; import VisualCandle from '../../model/visual-candle'; import { flat } from '../../utils/array.utils'; @@ -15,11 +17,12 @@ export class AreaDrawer implements SeriesDrawer { constructor(private config: ChartConfigComponentsChart) {} public draw( - ctx: CanvasRenderingContext2D, + canvasModel: CanvasModel, points: VisualSeriesPoint[][], model: DataSeriesModel, drawerConfig: ChartDrawerConfig, ) { + const ctx = canvasModel.ctx; if (model instanceof CandleSeriesModel) { // @ts-ignore const visualCandles: VisualCandle[] = flat(points); @@ -61,17 +64,28 @@ export class AreaDrawer implements SeriesDrawer { ctx.lineTo(floor(firstLineX), bottomY); ctx.closePath(); - let fillColor: CanvasGradient; if (drawerConfig.singleColor) { ctx.fillStyle = drawerConfig.singleColor; - } else { - ctx.fillStyle = - model.colors.areaTheme.startColor && model.colors.areaTheme.stopColor - ? ((fillColor = ctx.createLinearGradient(0, 0, 0, paneBounds.height)), - fillColor.addColorStop(0, model.colors.areaTheme.startColor), - fillColor.addColorStop(1, model.colors.areaTheme.stopColor), - fillColor) - : ''; + } else if (model.colors.areaTheme.startColor && model.colors.areaTheme.stopColor) { + if (isOffscreenCanvasModel(canvasModel)) { + const offscreenCtx = canvasModel.ctx; + // special method for gradient fill, because we can't transfer CanvasGradient directly to offscreen + offscreenCtx.setGradientFillStyle( + 0, + 0, + 0, + paneBounds.height, + 0, + model.colors.areaTheme.startColor, + 1, + model.colors.areaTheme.stopColor, + ); + } else { + const fillColor = ctx.createLinearGradient(0, 0, 0, paneBounds.height); + fillColor.addColorStop(0, model.colors.areaTheme.startColor); + fillColor.addColorStop(1, model.colors.areaTheme.stopColor); + ctx.fillStyle = fillColor; + } } ctx.fill(); } else { diff --git a/src/chart/drawers/chart-type-drawers/bar.drawer.ts b/src/chart/drawers/chart-type-drawers/bar.drawer.ts index bbef1a01..375f460c 100644 --- a/src/chart/drawers/chart-type-drawers/bar.drawer.ts +++ b/src/chart/drawers/chart-type-drawers/bar.drawer.ts @@ -5,6 +5,7 @@ */ import { ChartConfigComponentsChart } from '../../chart.config'; import { CandleSeriesModel } from '../../model/candle-series.model'; +import { CanvasModel } from '../../model/canvas.model'; import { DataSeriesModel, VisualSeriesPoint } from '../../model/data-series.model'; import VisualCandle from '../../model/visual-candle'; import { flat } from '../../utils/array.utils'; @@ -32,11 +33,12 @@ export class BarDrawer implements SeriesDrawer { } public draw( - ctx: CanvasRenderingContext2D, + canvasModel: CanvasModel, points: VisualSeriesPoint[][], candleSeries: DataSeriesModel, drawerConfig: ChartDrawerConfig, ) { + const ctx = canvasModel.ctx; if (candleSeries instanceof CandleSeriesModel) { // @ts-ignore const visualCandles: VisualCandle[] = flat(points); diff --git a/src/chart/drawers/chart-type-drawers/baseline.drawer.ts b/src/chart/drawers/chart-type-drawers/baseline.drawer.ts index 888fb05b..34994fad 100644 --- a/src/chart/drawers/chart-type-drawers/baseline.drawer.ts +++ b/src/chart/drawers/chart-type-drawers/baseline.drawer.ts @@ -6,6 +6,7 @@ import { CanvasBoundsContainer, CanvasElement } from '../../canvas/canvas-bounds-container'; import { BaselineModel } from '../../model/baseline.model'; import { CandleSeriesModel } from '../../model/candle-series.model'; +import { CanvasModel } from '../../model/canvas.model'; import { DataSeriesModel, VisualSeriesPoint } from '../../model/data-series.model'; import { Pixel } from '../../model/scaling/viewport.model'; import { flat } from '../../utils/array.utils'; @@ -15,11 +16,12 @@ export class BaselineDrawer implements SeriesDrawer { constructor(private baseLineModel: BaselineModel, private canvasBoundContainer: CanvasBoundsContainer) {} public draw( - ctx: CanvasRenderingContext2D, + canvasModel: CanvasModel, points: VisualSeriesPoint[][], model: DataSeriesModel, drawerConfig?: ChartDrawerConfig, ) { + const ctx = canvasModel.ctx; if (drawerConfig !== undefined && model instanceof CandleSeriesModel) { const visualCandles = flat(points); // calculate baseline diff --git a/src/chart/drawers/chart-type-drawers/candle.drawer.ts b/src/chart/drawers/chart-type-drawers/candle.drawer.ts index 0f134c68..2694906e 100644 --- a/src/chart/drawers/chart-type-drawers/candle.drawer.ts +++ b/src/chart/drawers/chart-type-drawers/candle.drawer.ts @@ -11,6 +11,7 @@ import { dpr, floorToDPR } from '../../utils/device/device-pixel-ratio.utils'; import { ChartDrawerConfig, SeriesDrawer, setLineWidth } from '../data-series.drawer'; import { DataSeriesModel, VisualSeriesPoint } from '../../model/data-series.model'; import { flat } from '../../utils/array.utils'; +import { CanvasModel } from '../../model/canvas.model'; export class CandleDrawer implements SeriesDrawer { constructor(private config: ChartConfigComponentsChart) {} @@ -36,7 +37,7 @@ export class CandleDrawer implements SeriesDrawer { //#endregion public draw( - ctx: CanvasRenderingContext2D, + canvasModel: CanvasModel, /** * You can pass two-dimension array to divide series into multiple parts */ @@ -44,6 +45,7 @@ export class CandleDrawer implements SeriesDrawer { model: DataSeriesModel, drawerConfig: ChartDrawerConfig, ) { + const ctx = canvasModel.ctx; if (model instanceof CandleSeriesModel) { // @ts-ignore const visualCandles: VisualCandle[] = flat(points); diff --git a/src/chart/drawers/chart-type-drawers/histogram.drawer.ts b/src/chart/drawers/chart-type-drawers/histogram.drawer.ts index 554520ec..219b03e3 100644 --- a/src/chart/drawers/chart-type-drawers/histogram.drawer.ts +++ b/src/chart/drawers/chart-type-drawers/histogram.drawer.ts @@ -3,8 +3,10 @@ * This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. * If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { isOffscreenCanvasModel } from '../../canvas/offscreen/canvas-offscreen-wrapper'; import { ChartConfigComponentsHistogram } from '../../chart.config'; import { CandleSeriesModel } from '../../model/candle-series.model'; +import { CanvasModel } from '../../model/canvas.model'; import { DataSeriesModel, VisualSeriesPoint } from '../../model/data-series.model'; import VisualCandle from '../../model/visual-candle'; import { floorToDPR } from '../../utils/device/device-pixel-ratio.utils'; @@ -14,11 +16,12 @@ export class HistogramDrawer implements SeriesDrawer { constructor(private config: ChartConfigComponentsHistogram) {} public draw( - ctx: CanvasRenderingContext2D, + canvasModel: CanvasModel, points: VisualSeriesPoint[][], model: DataSeriesModel, drawerConfig: ChartDrawerConfig, ) { + const ctx = canvasModel.ctx; if (model instanceof CandleSeriesModel) { // @ts-ignore const visualCandles: VisualCandle[] = points.flat(); @@ -45,18 +48,33 @@ export class HistogramDrawer implements SeriesDrawer { ctx.fillRect(baseX, closeY, width, capHeight); // the bar itself - const gradient = ctx.createLinearGradient(0, closeY + capHeight, 0, bottomY); if (drawerConfig.singleColor) { ctx.fillStyle = drawerConfig.singleColor; } else { - gradient.addColorStop(0, histogramColors[`${direction}Cap`]); - gradient.addColorStop(1, histogramColors[`${direction}Bottom`]); - ctx.fillStyle = gradient; + if (isOffscreenCanvasModel(canvasModel)) { + const offscreenCtx = canvasModel.ctx; + // special method for gradient fill, because we can't transfer CanvasGradient directly to offscreen + offscreenCtx.setGradientFillStyle( + 0, + closeY + capHeight, + 0, + bottomY, + 0, + histogramColors[`${direction}Cap`], + 1, + histogramColors[`${direction}Bottom`], + ); + } else { + const gradient = ctx.createLinearGradient(0, closeY + capHeight, 0, bottomY); + gradient.addColorStop(0, histogramColors[`${direction}Cap`]); + gradient.addColorStop(1, histogramColors[`${direction}Bottom`]); + ctx.fillStyle = gradient; + } } if (width === 0) { // just draw a vertical line ctx.beginPath(); - ctx.strokeStyle = gradient; + ctx.strokeStyle = histogramColors[`${direction}Cap`]; ctx.moveTo(baseX, closeY + capHeight); ctx.lineTo(baseX, bottomY); ctx.stroke(); diff --git a/src/chart/drawers/chart-type-drawers/line.drawer.ts b/src/chart/drawers/chart-type-drawers/line.drawer.ts index d7efbbdd..c164b805 100644 --- a/src/chart/drawers/chart-type-drawers/line.drawer.ts +++ b/src/chart/drawers/chart-type-drawers/line.drawer.ts @@ -9,16 +9,18 @@ import VisualCandle from '../../model/visual-candle'; import { ChartDrawerConfig, SeriesDrawer, setLineWidth } from '../data-series.drawer'; import { DataSeriesModel, VisualSeriesPoint } from '../../model/data-series.model'; import { flat } from '../../utils/array.utils'; +import { CanvasModel } from '../../model/canvas.model'; export class LineDrawer implements SeriesDrawer { constructor(private config: ChartConfigComponentsChart) {} public draw( - ctx: CanvasRenderingContext2D, + canvasModel: CanvasModel, points: VisualSeriesPoint[][], candleSeries: DataSeriesModel, drawerConfig: ChartDrawerConfig, ) { + const ctx = canvasModel.ctx; if (candleSeries instanceof CandleSeriesModel) { // @ts-ignore const visualCandles: VisualCandle[] = flat(points); diff --git a/src/chart/drawers/chart-type-drawers/scatter-plot.drawer.ts b/src/chart/drawers/chart-type-drawers/scatter-plot.drawer.ts index 7cb82d61..e10df221 100644 --- a/src/chart/drawers/chart-type-drawers/scatter-plot.drawer.ts +++ b/src/chart/drawers/chart-type-drawers/scatter-plot.drawer.ts @@ -4,6 +4,7 @@ * If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import { ScatterPlotStyle } from '../../chart.config'; +import { CanvasModel } from '../../model/canvas.model'; import { DataSeriesModel, VisualSeriesPoint } from '../../model/data-series.model'; import { flat } from '../../utils/array.utils'; import { floorToDPR } from '../../utils/device/device-pixel-ratio.utils'; @@ -15,11 +16,12 @@ export class ScatterPlotDrawer implements SeriesDrawer { constructor(private config: ScatterPlotStyle) {} public draw( - ctx: CanvasRenderingContext2D, + canvasModel: CanvasModel, points: VisualSeriesPoint[][], model: DataSeriesModel, drawerConfig: ChartDrawerConfig, ) { + const ctx = canvasModel.ctx; ctx.fillStyle = drawerConfig.singleColor ?? this.config.mainColor; for (const visualCandle of flat(points)) { ctx.beginPath(); diff --git a/src/chart/drawers/data-series-drawers/candle-series-wrapper.ts b/src/chart/drawers/data-series-drawers/candle-series-wrapper.ts index 4ae59599..14b5bb67 100644 --- a/src/chart/drawers/data-series-drawers/candle-series-wrapper.ts +++ b/src/chart/drawers/data-series-drawers/candle-series-wrapper.ts @@ -8,6 +8,7 @@ import { BoundsProvider } from '../../model/bounds.model'; import { DataSeriesModel, VisualSeriesPoint } from '../../model/data-series.model'; import { ChartDrawerConfig, SeriesDrawer } from '../data-series.drawer'; import { clipToBounds } from '../../utils/canvas/canvas-drawing-functions.utils'; +import { CanvasModel } from '../../model/canvas.model'; export const candleTypesList: BarType[] = [ 'candle', @@ -28,14 +29,15 @@ export class CandleSeriesWrapper implements SeriesDrawer { constructor(private drawer: SeriesDrawer, private config: FullChartConfig, private chartBounds: BoundsProvider) {} draw( - ctx: CanvasRenderingContext2D, + canvasModel: CanvasModel, allPoints: VisualSeriesPoint[][], model: DataSeriesModel, config: ChartDrawerConfig, ): void { + const ctx = canvasModel.ctx; if (this.isChartTypeAllowed()) { this.beforeDraw(ctx); - this.drawer.draw(ctx, allPoints, model, config); + this.drawer.draw(canvasModel, allPoints, model, config); this.afterDraw(ctx, model); } } diff --git a/src/chart/drawers/data-series-drawers/color-candle.drawer.ts b/src/chart/drawers/data-series-drawers/color-candle.drawer.ts index 530f5898..4355166e 100644 --- a/src/chart/drawers/data-series-drawers/color-candle.drawer.ts +++ b/src/chart/drawers/data-series-drawers/color-candle.drawer.ts @@ -4,6 +4,7 @@ * If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import { ChartModel } from '../../components/chart/chart.model'; +import { CanvasModel } from '../../model/canvas.model'; import { DataSeriesModel, VisualSeriesPoint } from '../../model/data-series.model'; import { unitToPixels } from '../../model/scaling/viewport.model'; import { floorToDPR } from '../../utils/device/device-pixel-ratio.utils'; @@ -18,11 +19,12 @@ export class ColorCandleDrawer implements SeriesDrawer { constructor(private chartModel: ChartModel) {} draw( - ctx: CanvasRenderingContext2D, + canvasModel: CanvasModel, allPoints: VisualSeriesPoint[][], model: DataSeriesModel, drawerConfig: ChartDrawerConfig, ): void { + const ctx = canvasModel.ctx; allPoints.forEach((points, idx) => { const config = model.getPaintConfig(idx); ctx.fillStyle = drawerConfig.singleColor ?? config.color; diff --git a/src/chart/drawers/data-series-drawers/difference-cloud.drawer.ts b/src/chart/drawers/data-series-drawers/difference-cloud.drawer.ts index ea111841..0315af08 100644 --- a/src/chart/drawers/data-series-drawers/difference-cloud.drawer.ts +++ b/src/chart/drawers/data-series-drawers/difference-cloud.drawer.ts @@ -10,6 +10,7 @@ import { buildLinePath } from './data-series-drawers.utils'; import { Point } from '../../inputlisteners/canvas-input-listener.component'; import { firstOf, lastOf } from '../../utils/array.utils'; import { toRGBA } from '../../utils/color.utils'; +import { CanvasModel } from '../../model/canvas.model'; /** * Point used to draw difference type indicator (clouds) (e.g. Ichimoku indicator) @@ -22,11 +23,12 @@ export class DifferenceCloudDrawer implements SeriesDrawer { constructor() {} draw( - ctx: CanvasRenderingContext2D, + canvasModel: CanvasModel, allPoints: VisualSeriesPoint[][], model: DataSeriesModel, drawerConfig: ChartDrawerConfig, ): void { + const ctx = canvasModel.ctx; if (model.config.visible) { // draw main line allPoints.forEach((points, idx) => { diff --git a/src/chart/drawers/data-series-drawers/histogram.drawer.ts b/src/chart/drawers/data-series-drawers/histogram.drawer.ts index dbc07f64..3439a1a5 100644 --- a/src/chart/drawers/data-series-drawers/histogram.drawer.ts +++ b/src/chart/drawers/data-series-drawers/histogram.drawer.ts @@ -3,6 +3,7 @@ * This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. * If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { CanvasModel } from '../../model/canvas.model'; import { DataSeriesModel, VisualSeriesPoint } from '../../model/data-series.model'; import { floor } from '../../utils/math.utils'; import { ChartDrawerConfig, SeriesDrawer, setLineWidth } from '../data-series.drawer'; @@ -11,11 +12,12 @@ export class HistogramDrawer implements SeriesDrawer { constructor() {} draw( - ctx: CanvasRenderingContext2D, + canvasModel: CanvasModel, allPoints: VisualSeriesPoint[][], model: DataSeriesModel, drawerConfig: ChartDrawerConfig, ): void { + const ctx = canvasModel.ctx; const zero = model.view.toY(0); allPoints.forEach((points, idx) => { // odd width is crucial to draw histogram without antialiasing diff --git a/src/chart/drawers/data-series-drawers/linear.drawer.ts b/src/chart/drawers/data-series-drawers/linear.drawer.ts index 77c9193d..cc033546 100644 --- a/src/chart/drawers/data-series-drawers/linear.drawer.ts +++ b/src/chart/drawers/data-series-drawers/linear.drawer.ts @@ -3,6 +3,7 @@ * This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. * If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { CanvasModel } from '../../model/canvas.model'; import { DataSeriesModel, VisualSeriesPoint } from '../../model/data-series.model'; import { Viewable } from '../../model/scaling/viewport.model'; import { ChartDrawerConfig, SeriesDrawer, setLineWidth } from '../data-series.drawer'; @@ -12,11 +13,12 @@ export class LinearDrawer implements SeriesDrawer { constructor() {} draw( - ctx: CanvasRenderingContext2D, + canvasModel: CanvasModel, allPoints: VisualSeriesPoint[][], model: DataSeriesModel, drawerConfig: ChartDrawerConfig, ): void { + const ctx = canvasModel.ctx; allPoints.forEach((points, idx) => { const config = model.getPaintConfig(idx); setLineWidth(ctx, config.lineWidth, model, drawerConfig, config.hoveredLineWidth); diff --git a/src/chart/drawers/data-series-drawers/points.drawer.ts b/src/chart/drawers/data-series-drawers/points.drawer.ts index 24fa91ce..aa9bb4a2 100644 --- a/src/chart/drawers/data-series-drawers/points.drawer.ts +++ b/src/chart/drawers/data-series-drawers/points.drawer.ts @@ -3,6 +3,7 @@ * This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. * If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { CanvasModel } from '../../model/canvas.model'; import { DataSeriesModel, VisualSeriesPoint } from '../../model/data-series.model'; import { ChartDrawerConfig, SeriesDrawer } from '../data-series.drawer'; @@ -10,11 +11,12 @@ export class PointsDrawer implements SeriesDrawer { constructor() {} draw( - ctx: CanvasRenderingContext2D, + canvasModel: CanvasModel, allPoints: VisualSeriesPoint[][], model: DataSeriesModel, drawerConfig: ChartDrawerConfig, ): void { + const ctx = canvasModel.ctx; allPoints.forEach((points, idx) => { const config = model.getPaintConfig(idx); const radius = config.lineWidth; diff --git a/src/chart/drawers/data-series-drawers/text.drawer.ts b/src/chart/drawers/data-series-drawers/text.drawer.ts index ef47a309..f14d9a03 100644 --- a/src/chart/drawers/data-series-drawers/text.drawer.ts +++ b/src/chart/drawers/data-series-drawers/text.drawer.ts @@ -4,6 +4,7 @@ * If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import { FullChartConfig } from '../../chart.config'; +import { CanvasModel } from '../../model/canvas.model'; import { DataSeriesModel, VisualSeriesPoint } from '../../model/data-series.model'; import { calculateSymbolHeight, calculateTextWidth } from '../../utils/canvas/canvas-font-measure-tool.utils'; import { ChartDrawerConfig, SeriesDrawer } from '../data-series.drawer'; @@ -12,11 +13,12 @@ export class TextDrawer implements SeriesDrawer { constructor(private config: FullChartConfig) {} draw( - ctx: CanvasRenderingContext2D, + canvasModel: CanvasModel, allPoints: VisualSeriesPoint[][], model: DataSeriesModel, drawerConfig: ChartDrawerConfig, ): void { + const ctx = canvasModel.ctx; ctx.save(); allPoints.forEach((points, idx) => { const config = model.getPaintConfig(idx); @@ -25,8 +27,8 @@ export class TextDrawer implements SeriesDrawer { ctx.font = font; points.forEach(p => { const text = model.getTextForPoint(p); - const textWidth = calculateTextWidth(text, ctx, font); - const textHeight = calculateSymbolHeight(font, ctx); + const textWidth = calculateTextWidth(text, font); + const textHeight = calculateSymbolHeight(font); const x = model.view.toX(p.centerUnit) - textWidth / 2; const y = model.view.toY(p.close) + textHeight; ctx.fillText(text, x, y); diff --git a/src/chart/drawers/data-series-drawers/trend-histogram.drawer.ts b/src/chart/drawers/data-series-drawers/trend-histogram.drawer.ts index 1374c831..cdbbfb26 100644 --- a/src/chart/drawers/data-series-drawers/trend-histogram.drawer.ts +++ b/src/chart/drawers/data-series-drawers/trend-histogram.drawer.ts @@ -4,6 +4,7 @@ * If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { CanvasModel } from '../../model/canvas.model'; import { VisualSeriesPoint, DataSeriesModel } from '../../model/data-series.model'; import { flat } from '../../utils/array.utils'; import { floor } from '../../utils/math.utils'; @@ -12,7 +13,8 @@ import { SeriesDrawer } from '../data-series.drawer'; export class TrendHistogramDrawer implements SeriesDrawer { constructor() {} - draw(ctx: CanvasRenderingContext2D, allPoints: VisualSeriesPoint[][], model: DataSeriesModel): void { + draw(canvasModel: CanvasModel, allPoints: VisualSeriesPoint[][], model: DataSeriesModel): void { + const ctx = canvasModel.ctx; const zero = model.view.toY(0); // here I do flat, thinks that there is no more that 1 series const allPointsFlat = flat(allPoints); diff --git a/src/chart/drawers/data-series-drawers/triangle.drawer.ts b/src/chart/drawers/data-series-drawers/triangle.drawer.ts index 89f5e75b..d5e316eb 100644 --- a/src/chart/drawers/data-series-drawers/triangle.drawer.ts +++ b/src/chart/drawers/data-series-drawers/triangle.drawer.ts @@ -3,6 +3,7 @@ * This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. * If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { CanvasModel } from '../../model/canvas.model'; import { DataSeriesModel, VisualSeriesPoint } from '../../model/data-series.model'; import { ChartDrawerConfig, SeriesDrawer } from '../data-series.drawer'; @@ -10,11 +11,12 @@ export class TriangleDrawer implements SeriesDrawer { constructor() {} draw( - ctx: CanvasRenderingContext2D, + canvasModel: CanvasModel, allPoints: VisualSeriesPoint[][], model: DataSeriesModel, drawerConfig: ChartDrawerConfig, ): void { + const ctx = canvasModel.ctx; allPoints.forEach((points, idx) => { const config = model.getPaintConfig(idx); ctx.fillStyle = drawerConfig.singleColor ?? config.color; diff --git a/src/chart/drawers/data-series.drawer.ts b/src/chart/drawers/data-series.drawer.ts index 54c0b868..aefa1b7d 100644 --- a/src/chart/drawers/data-series.drawer.ts +++ b/src/chart/drawers/data-series.drawer.ts @@ -16,7 +16,7 @@ export interface ChartDrawerConfig { export interface SeriesDrawer { draw: ( - ctx: CanvasRenderingContext2D, + canvasModel: CanvasModel, /** * You can pass two-dimension array to divide series into multiple parts */ @@ -47,12 +47,12 @@ export class DataSeriesDrawer implements DynamicModelDrawer { if (model) { ctx.save(); pane && clipToBounds(ctx, pane.getBounds()); - this.drawSeries(ctx, model); + this.drawSeries(canvasModel, model); ctx.restore(); } } - public drawSeries(ctx: CanvasRenderingContext2D, series: DataSeriesModel) { + public drawSeries(canvasModel: CanvasModel, series: DataSeriesModel) { if (series.config.visible) { const paintTool = series.config.type; const drawer = this.seriesDrawers[paintTool]; @@ -60,7 +60,7 @@ export class DataSeriesDrawer implements DynamicModelDrawer { const viewportSeries = series.getSeriesInViewport(series.scale.xStart - 1, series.scale.xEnd + 1); if (viewportSeries && viewportSeries.length >= 1) { // +- 1 to correctly draw points which are partly inside bounds - drawer.draw(ctx, viewportSeries, series, {}); + drawer.draw(canvasModel, viewportSeries, series, {}); } } else { console.error(`Data series drawer with type ${paintTool} isn't registered!`); diff --git a/src/chart/drawers/drawing-manager.ts b/src/chart/drawers/drawing-manager.ts index cfd96570..27de316f 100644 --- a/src/chart/drawers/drawing-manager.ts +++ b/src/chart/drawers/drawing-manager.ts @@ -3,14 +3,23 @@ * This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. * If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { Remote } from 'comlink'; +import { + CanvasOffscreenContext2D, + isOffscreenCanvasModel, + strsToSync, +} from '../canvas/offscreen/canvas-offscreen-wrapper'; +import { initOffscreenWorker, isOffscreenWorkerAvailable } from '../canvas/offscreen/init-offscreen'; +import { OffscreenWorker } from '../canvas/offscreen/offscreen-worker'; import EventBus from '../events/event-bus'; import { EVENT_DRAW } from '../events/events'; import { ChartResizeHandler } from '../inputhandlers/chart-resize.handler'; -import { MIN_SUPPORTED_CANVAS_SIZE } from '../model/canvas.model'; +import { CanvasModel, MIN_SUPPORTED_CANVAS_SIZE } from '../model/canvas.model'; import { arrayIntersect, reorderArray } from '../utils/array.utils'; import { StringTMap } from '../utils/object.utils'; import { animationFrameThrottled } from '../utils/performance/request-animation-frame-throttle.utils'; import { uuid } from '../utils/uuid.utils'; +import { FullChartConfig } from '../chart.config'; export const HIT_TEST_PREFIX = 'HIT_TEST_'; @@ -51,10 +60,32 @@ export class DrawingManager { private drawersMap: StringTMap = {}; private readonly drawHitTestCanvas: () => void; - private canvasIdsList: Array | undefined = []; + private canvasIdsList: Record = {}; private animFrameId = `draw_${uuid()}`; + private readyDraw = false; + private offscreenWorker?: Remote; + private offscreenCanvases: CanvasModel[] = []; - constructor(eventBus: EventBus, private chartResizeHandler: ChartResizeHandler) { + constructor( + private config: FullChartConfig, + eventBus: EventBus, + private chartResizeHandler: ChartResizeHandler, + canvases: CanvasModel[], + ) { + for (const canvas of canvases) { + this.canvasIdsList[canvas.canvasId] = true; + } + if (config.experimental.offscreen.enabled && isOffscreenWorkerAvailable) { + initOffscreenWorker(canvases, config.experimental.offscreen.fonts).then(worker => { + this.offscreenCanvases = canvases.filter(isOffscreenCanvasModel); + this.offscreenWorker = worker; + this.readyDraw = true; + eventBus.fireDraw(); + }); + } else { + this.readyDraw = true; + eventBus.fireDraw(); + } // eventBus.on(EVENT_DRAW_LAST_CANDLE, () => animationFrameThrottled(this.animFrameId + 'last', () => this.drawLastBar())); this.drawHitTestCanvas = () => { this.drawingOrder.forEach(drawer => { @@ -65,33 +96,66 @@ export class DrawingManager { }; eventBus.on(EVENT_DRAW, (canvasIds: Array) => { if (chartResizeHandler.wasResized()) { - // if we fire bus.fireDraw() without arguments(undefined) we need to keep canvasIdsList empty - if (this.canvasIdsList) { - if (canvasIds && canvasIds.length !== 0) { - this.canvasIdsList = this.canvasIdsList.concat(canvasIds); - } else { - // make this undefined until the end of frame - this will redraw everything - this.canvasIdsList = undefined; + // if we fire bus.fireDraw() without arguments(undefined) we need to redraw all canvases + if (!canvasIds) { + for (const canvasId of Object.keys(this.canvasIdsList)) { + this.canvasIdsList[canvasId] = true; + } + } else { + for (const canvasId of canvasIds) { + this.canvasIdsList[canvasId] = true; } } - animationFrameThrottled(this.animFrameId, () => { - this.forceDraw(this.canvasIdsList); - this.canvasIdsList = []; + animationFrameThrottled(this.animFrameId, async() => { + if (!this.isDrawable()) { + // previous rendering cycle is not finished yet, schedule another draw + eventBus.fireDraw([]); + return; + } + const canvasIds = Object.entries(this.canvasIdsList).filter(([, v]) => v).map(([k]) => k); + this.forceDraw(); this.drawHitTestCanvas(); + for (const canvasId of canvasIds) { + this.canvasIdsList[canvasId] = false; + } + // we use mutex in order to avoid situation when canvas resize happened during offscreen rendering + await this.chartResizeHandler.mutex.calculateSafe(() => this.drawOffscreen()); + this.readyDraw = true; }); } }); } + private async drawOffscreen() { + if (this.offscreenWorker === undefined) { + return; + } + // commit method exists only in offscreen context class and adds END_OF_FILE marker to the buffer + // so worker knows where is the end of commands + this.offscreenCanvases.forEach(canvas => canvas.ctx.commit()); + if (strsToSync.length) { + await this.offscreenWorker.syncStrings(strsToSync); + strsToSync.length = 0; + } + await this.offscreenWorker.executeCanvasCommands(this.offscreenCanvases.map(canvas => canvas.idx)); + } + /** * Updates canvases' sizes and executes redraw without animation frame. * This is required for multi-chart canvas update synchronization. * If all canvases update in separate animation frames - we see visual lag. Instead we should do all updates and then redraw. * @doc-tags tricky,canvas,resize */ - public redrawCanvasesImmediate() { + public async redrawCanvasesImmediate() { + // not safe and meaningless to use in offscreen mode + // I'm not sure if it's even possible because of async nature of offscreen + // of course we can implement some kind of spinlock, but it's insane + if (this.config.experimental.offscreen.enabled) { + return; + } this.chartResizeHandler.fireUpdates(); this.forceDraw(); + this.readyDraw = true; } drawLastBar() { @@ -107,6 +171,7 @@ export class DrawingManager { if (!this.isDrawable()) { return; } + this.readyDraw = false; this.drawingOrder.forEach(drawerName => { if (drawerName.indexOf(HIT_TEST_PREFIX) === -1) { const drawer = this.drawersMap[drawerName]; @@ -129,6 +194,7 @@ export class DrawingManager { */ public isDrawable(): boolean { return ( + this.readyDraw && (this.chartResizeHandler.previousBCR?.height ?? 0) > MIN_SUPPORTED_CANVAS_SIZE.width && (this.chartResizeHandler.previousBCR?.width ?? 0) > MIN_SUPPORTED_CANVAS_SIZE.height ); diff --git a/src/chart/drawers/ht-data-series.drawer.ts b/src/chart/drawers/ht-data-series.drawer.ts index 8cbbfb02..cefb1622 100644 --- a/src/chart/drawers/ht-data-series.drawer.ts +++ b/src/chart/drawers/ht-data-series.drawer.ts @@ -5,10 +5,11 @@ */ import { PaneManager } from '../components/pane/pane-manager.component'; import { DataSeriesModel } from '../model/data-series.model'; -import { HitTestCanvasModel } from '../model/hit-test-canvas.model'; +import { HitTestCanvasModel, idToColor } from '../model/hit-test-canvas.model'; import { ChartDrawerConfig, SeriesDrawer } from './data-series.drawer'; import { clipToBounds } from '../utils/canvas/canvas-drawing-functions.utils'; import { Drawer } from './drawing-manager'; +import { CanvasModel } from '../model/canvas.model'; /*** * HitTest Chart drawer. It's used to draw hit test for chart types on the hit-test canvas. @@ -27,24 +28,24 @@ export class HTDataSeriesDrawer implements Drawer { this.paneManager.yExtents.forEach(comp => { ctx.save(); clipToBounds(ctx, comp.getBounds()); - comp.dataSeries.forEach(series => this.drawSeries(ctx, series)); + comp.dataSeries.forEach(series => this.drawSeries(this.canvasModel, series)); ctx.restore(); }); } } - public drawSeries(ctx: CanvasRenderingContext2D, series: DataSeriesModel) { + public drawSeries(canvasModel: CanvasModel, series: DataSeriesModel) { if (series.config.visible) { const paintTool = series.config.type; const drawer = this.seriesDrawers[paintTool]; if (drawer) { const drawConfig: ChartDrawerConfig = { - singleColor: this.canvasModel.idToColor(series.htId), + singleColor: idToColor(series.htId), forceBold: 7, }; // +- 1 to correctly draw points which are partly inside bounds drawer.draw( - ctx, + canvasModel, series.getSeriesInViewport(series.scale.xStart - 1, series.scale.xEnd + 1), series, drawConfig, diff --git a/src/chart/inputhandlers/chart-resize.handler.ts b/src/chart/inputhandlers/chart-resize.handler.ts index a4c9efee..b17c4f76 100644 --- a/src/chart/inputhandlers/chart-resize.handler.ts +++ b/src/chart/inputhandlers/chart-resize.handler.ts @@ -10,6 +10,7 @@ import EventBus from '../events/event-bus'; import { EVENT_DRAW, EVENT_RESIZED } from '../events/events'; import { uuid } from '../utils/uuid.utils'; import { animationFrameThrottledPrior } from '../utils/performance/request-animation-frame-throttle.utils'; +import { createMutex } from '../utils/mutex'; export type PickedDOMRect = Pick; @@ -21,6 +22,7 @@ export class ChartResizeHandler { public previousBCR: PickedDOMRect | undefined = undefined; private animFrameId = `resize_${uuid()}`; public canvasResized = new Subject(); + public mutex = createMutex(); constructor( private frameElement: HTMLElement, private resizerElement: HTMLElement, @@ -56,7 +58,7 @@ export class ChartResizeHandler { * @memberof ClassName * @returns {void} */ - public fireUpdates() { + public async fireUpdates() { const resizerElementBCR = this.resizerElement.getBoundingClientRect(); const newBCR = { x: resizerElementBCR.x, @@ -69,7 +71,9 @@ export class ChartResizeHandler { } if (this.previousBCR === undefined || this.isBCRDimensionsDiffer(this.previousBCR, newBCR)) { this.previousBCR = newBCR; - this.canvasModels.forEach(model => this.previousBCR && model.updateDPR(this.previousBCR)); + await Promise.all(this.canvasModels.map(model => model.canvasReady)); + // we use mutex in order to avoid situation when canvas resize happened during offscreen rendering + await this.mutex.calculateSafe(() => this.canvasModels.forEach(model => model.updateDPR(newBCR))); this.canvasResized.next(newBCR); this.bus.fire(EVENT_RESIZED, newBCR); this.bus.fire(EVENT_DRAW); diff --git a/src/chart/model/canvas.model.ts b/src/chart/model/canvas.model.ts index 8d769af6..858d1f69 100644 --- a/src/chart/model/canvas.model.ts +++ b/src/chart/model/canvas.model.ts @@ -3,10 +3,11 @@ * This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. * If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { CanvasOffscreenContext2D, isOffscreenCanvasModel } from '../canvas/offscreen/canvas-offscreen-wrapper'; import { BarType, FullChartConfig } from '../chart.config'; import EventBus from '../events/event-bus'; import { PickedDOMRect } from '../inputhandlers/chart-resize.handler'; -import { DrawingManager } from '../drawers/drawing-manager'; +import { constVoid } from '../utils/function.utils'; /** * The minimum supported canvas size in chart-core (in pixels). @@ -18,31 +19,45 @@ export const MIN_SUPPORTED_CANVAS_SIZE = { height: 20, }; -export class CanvasModel { - private readonly context: CanvasRenderingContext2D; +export type CanvasModelOptions = CanvasRenderingContext2DSettings & { + offscreen?: boolean; + // size of SharedArrayBuffer in bytes + // if size is not enough, then the chart will crash on render + offscreenBufferSize?: number; +}; + +export class CanvasModel { + private readonly context: T; public parent: HTMLElement; public width: number = 0; public height: number = 0; public prevHeight: number = 0; public prevWidth: number = 0; - private readonly _canvasId: string; + public idx: number = 0; + public readonly _canvasId: string; type: CanvasBarType = CANDLE_TYPE; + + public canvasReady: Promise = Promise.resolve(); + public fireCanvasReady: () => unknown = constVoid; + constructor( + ctx: T, private eventBus: EventBus, public canvas: HTMLCanvasElement, - public drawingManager: DrawingManager, canvasModels: CanvasModel[], private resizer?: HTMLElement, - options: CanvasRenderingContext2DSettings = {}, + public options: CanvasModelOptions = {}, ) { + if (options.offscreen) { + this.canvasReady = new Promise(resolve => { + this.fireCanvasReady = resolve; + }); + } canvasModels.push(this); this.parent = findHeightParent(canvas); - const ctx = canvas.getContext('2d', options); - if (ctx === null) { - throw new Error("Couldn't get 2d context????"); - } - this.context = ctx; + this._canvasId = canvas.getAttribute('data-element') ?? ''; + this.context = ctx; this.updateCanvasWidthHeight(canvas, this.getChartResizerElement().getBoundingClientRect()); } /** @@ -52,21 +67,26 @@ export class CanvasModel { */ updateDPR(bcr: PickedDOMRect | ClientRect) { const { width, height } = bcr; - const dpi = window.devicePixelRatio; this.canvas.style.height = height + 'px'; this.canvas.style.width = width + 'px'; - this.canvas.width = width * dpi; - this.canvas.height = height * dpi; + const dpi = window.devicePixelRatio; + if (isOffscreenCanvasModel(this)) { + this.ctx.width = width * dpi; + this.ctx.height = height * dpi; + } else { + this.canvas.width = width * dpi; + this.canvas.height = height * dpi; + } + this.ctx.scale(dpi, dpi); this.width = width; this.height = height; - this.ctx.scale(dpi, dpi); } get canvasId(): string { return this._canvasId; } - get ctx(): CanvasRenderingContext2D { + get ctx(): T { return this.context; } @@ -178,31 +198,17 @@ const TYPES: Partial> = { * @param {CanvasModel[]} canvasModels - An array of canvas models to add the new model to. * * @returns {CanvasModel} The newly created canvas model. - -export function createMainCanvasModel( - eventBus, - canvas, - resizer, - barType, - config, - drawingManager, - canvasModels, -) { - const canvasModel = createCanvasModel(eventBus, canvas, config, drawingManager, canvasModels, resizer); - // @ts-ignore - canvasModel.type = TYPES[barType] ?? CANDLE_TYPE; - return canvasModel; -}*/ + */ export function createMainCanvasModel( eventBus: EventBus, canvas: HTMLCanvasElement, resizer: HTMLElement, barType: BarType, config: FullChartConfig, - drawingManager: DrawingManager, canvasModels: CanvasModel[], + options?: CanvasModelOptions, ): CanvasModel { - const canvasModel = createCanvasModel(eventBus, canvas, config, drawingManager, canvasModels, resizer); + const canvasModel = createCanvasModel(eventBus, canvas, config, canvasModels, resizer, options); // @ts-ignore canvasModel.type = TYPES[barType] ?? CANDLE_TYPE; return canvasModel; @@ -224,16 +230,30 @@ export function createCanvasModel( eventBus: EventBus, canvas: HTMLCanvasElement, config: FullChartConfig, - drawingManager: DrawingManager, canvasModels: CanvasModel[], resizer?: HTMLElement, - options?: CanvasRenderingContext2DSettings, + options?: CanvasModelOptions, ): CanvasModel { - const canvasModel = new CanvasModel(eventBus, canvas, drawingManager, canvasModels, resizer, options); + const canvasModel = new CanvasModel( + getCanvasContext(canvas, options), + eventBus, + canvas, + canvasModels, + resizer, + options, + ); initCanvasWithConfig(canvasModel, config); return canvasModel; } +export const getCanvasContext = (canvas: HTMLCanvasElement, options?: CanvasModelOptions): CanvasRenderingContext2D => { + const ctx = options?.offscreen ? new CanvasOffscreenContext2D(canvas) : canvas.getContext('2d', options); + if (ctx === null) { + throw new Error("Couldn't get 2d context. Canvas is not supported."); + } + return ctx; +}; + /** * Initializes a canvas with a given configuration. * @param {CanvasModel} canvasModel - The canvas model to be initialized. diff --git a/src/chart/model/hit-test-canvas.model.ts b/src/chart/model/hit-test-canvas.model.ts index 74181fd8..31e9cc87 100644 --- a/src/chart/model/hit-test-canvas.model.ts +++ b/src/chart/model/hit-test-canvas.model.ts @@ -7,11 +7,12 @@ import { merge, Observable, Subject, Subscription, animationFrameScheduler, Beha import { map, throttleTime } from 'rxjs/operators'; import { CanvasBoundsContainer, CanvasElement } from '../canvas/canvas-bounds-container'; import { CursorType, FullChartConfig } from '../chart.config'; -import { CanvasModel, initCanvasWithConfig } from './canvas.model'; -import { DrawingManager } from '../drawers/drawing-manager'; import EventBus from '../events/event-bus'; import { CanvasInputListenerComponent, Point } from '../inputlisteners/canvas-input-listener.component'; import { animationFrameId } from '../utils/performance/request-animation-frame-throttle.utils'; +import { CanvasModel, getCanvasContext, initCanvasWithConfig } from './canvas.model'; +import { isOffscreenWorkerAvailable, offscreenWorker } from '../canvas/offscreen/init-offscreen'; +import { isOffscreenCanvasModel } from '../canvas/offscreen/canvas-offscreen-wrapper'; const bigPrimeNumber = 317; @@ -48,16 +49,17 @@ export class HitTestCanvasModel extends CanvasModel { canvas: HTMLCanvasElement, private canvasInputListener: CanvasInputListenerComponent, private canvasBoundsContainer: CanvasBoundsContainer, - drawingManager: DrawingManager, chartConfig: FullChartConfig, canvasModels: CanvasModel[], resizer?: HTMLElement, ) { - super(eventBus, canvas, drawingManager, canvasModels, resizer, { + const options = { willReadFrequently: true, - // set to false to visually see hit test drawers objects (the canvas should also be visible) desynchronized: true, - }); + offscreen: chartConfig.experimental.offscreen.enabled && isOffscreenWorkerAvailable, + offscreenBufferSize: chartConfig.experimental.offscreen.bufferSizes.hitTestCanvas, + }; + super(getCanvasContext(canvas, options), eventBus, canvas, canvasModels, resizer, options); initCanvasWithConfig(this, chartConfig); canvas.style.visibility = 'hidden'; this.enableUserControls(); @@ -148,26 +150,6 @@ export class HitTestCanvasModel extends CanvasModel { this.hitTestSubscribers = this.hitTestSubscribers.filter(sub => sub === subscriber); } - /** - * Converts a number to a hexadecimal color code. - * @param {number} id - The number to be converted. - * @returns {string} - The hexadecimal color code. - */ - public idToColor(id: number): string { - const hex = (id * bigPrimeNumber).toString(16); - return '#000000'.substr(0, 7 - hex.length) + hex; - } - - /** - * This function takes a number representing a color and returns the corresponding ID by dividing it by a big prime number. - * - * @param {number} color - The number representing the color. - * @returns {number} - The ID corresponding to the color. - */ - public colorToId(color: number): number { - return color / bigPrimeNumber; - } - /** * Observes hovered on element event, provides hovered element model when move in. */ @@ -196,7 +178,7 @@ export class HitTestCanvasModel extends CanvasModel { return this.rightClickSubject.asObservable(); } - private curImgData: Uint8ClampedArray = new Uint8ClampedArray(4); + private lastColorId: number = -1; private prevAnimationFrameId = -1; /** * Retrieves the pixel data at the specified coordinates. @@ -206,25 +188,19 @@ export class HitTestCanvasModel extends CanvasModel { * @param {number} y - The y-coordinate of the pixel. * @returns {Uint8ClampedArray} - The pixel data at the specified coordinates. */ - private getPixel(x: number, y: number): Uint8ClampedArray { + public getPixel(x: number, y: number): Uint8ClampedArray { const dpr = window.devicePixelRatio; - // it's heavy operation, so use cached value if possible - if (this.prevAnimationFrameId !== animationFrameId) { - this.curImgData = this.ctx.getImageData(x * dpr, y * dpr, 1, 1).data; - this.prevAnimationFrameId = animationFrameId; - } - return this.curImgData; + const data = this.ctx.getImageData(x * dpr, y * dpr, 1, 1).data; + return data; } /** * Resolves ht model based on the provided point * @param point - The point for which to resolve model */ - public resolveModel(point: Point): unknown { - const data = this.getPixel(point.x, point.y); - const id = this.colorToId(data[0] * 65536 + data[1] * 256 + data[2]); - const idNumber = Number(id); - const [subscriberToHit] = sortSubscribers(this.hitTestSubscribers, idNumber); + public async resolveModel(point: Point): Promise { + const id = await this.getColorId(point); + const [subscriberToHit] = sortSubscribers(this.hitTestSubscribers, id); const model = subscriberToHit?.lookup(id); return model; } @@ -234,31 +210,44 @@ export class HitTestCanvasModel extends CanvasModel { * @param point - The point for which to resolve cursor type * @returns - The resolved cursor type, if any */ - public resolveCursor(point: Point): CursorType | undefined { + public async resolveCursor(point: Point): Promise { // do not spend time on resolving cursor if there are no subscribers that need it if (!this.hitTestSubscribers.some(s => s.resolveCursor !== undefined)) { return undefined; } - const data = this.getPixel(point.x, point.y); - const id = this.colorToId(data[0] * 65536 + data[1] * 256 + data[2]); - const idNumber = Number(id); - const [subscriberToHit] = sortSubscribers(this.hitTestSubscribers, idNumber); + const id = await this.getColorId(point); + const [subscriberToHit] = sortSubscribers(this.hitTestSubscribers, id); const model = subscriberToHit?.lookup(id); return subscriberToHit?.resolveCursor?.(point, model); } + private getColorIdSync(point: Point): number { + const data = this.getPixel(point.x, point.y); + const id = colorToId(data[0] * 65536 + data[1] * 256 + data[2]); + return id; + } + + private async getColorId(point: Point): Promise { + // it's heavy operation, so use cached value if possible + if (this.prevAnimationFrameId !== animationFrameId) { + this.lastColorId = isOffscreenCanvasModel(this) && offscreenWorker + ? await offscreenWorker.getColorId(this.idx, point.x, point.y) + : this.getColorIdSync(point); + this.prevAnimationFrameId = animationFrameId; + } + return this.lastColorId; + } + /** * Private method that handles hit test events. * @param {Point} point - The point where the event occurred. * @param {HitTestEvents} event - The type of event that occurred. * @returns {void} */ - private eventHandler(point: Point, event: HitTestEvents): void { - const data = this.getPixel(point.x, point.y); - const id = this.colorToId(data[0] * 65536 + data[1] * 256 + data[2]); - const idNumber = Number(id); - const [subscriberToHit, restSubs] = sortSubscribers(this.hitTestSubscribers, idNumber); + private async eventHandler(point: Point, event: HitTestEvents) { + const id = await this.getColorId(point); + const [subscriberToHit, restSubs] = sortSubscribers(this.hitTestSubscribers, id); const model = subscriberToHit?.lookup(id); const hitTestEvent = { @@ -316,6 +305,24 @@ export interface HitTestSubscriber { resolveCursor?(point: Point, model?: T): CursorType | undefined; } +/** + * This function takes a number representing a color and returns the corresponding ID by dividing it by a big prime number. + * + * @param {number} color - The number representing the color. + * @returns {number} - The ID corresponding to the color. + */ +export const colorToId = (color: number): number => color / bigPrimeNumber; + +/** + * Converts a number to a hexadecimal color code. + * @param {number} id - The number to be converted. + * @returns {string} - The hexadecimal color code. + */ +export const idToColor = (id: number): string => { + const hex = (id * bigPrimeNumber).toString(16); + return '#000000'.substr(0, 7 - hex.length) + hex; +}; + export interface HitTestEvent { readonly point: Point; readonly model: T; diff --git a/src/chart/utils/canvas/canvas-font-measure-tool.utils.ts b/src/chart/utils/canvas/canvas-font-measure-tool.utils.ts index 7a998c07..964845cf 100644 --- a/src/chart/utils/canvas/canvas-font-measure-tool.utils.ts +++ b/src/chart/utils/canvas/canvas-font-measure-tool.utils.ts @@ -3,6 +3,10 @@ * This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. * If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +// Regular 2d context is used only for measuring text +// eslint-disable-next-line @typescript-eslint/no-non-null-assertion +export const ctxForMeasure: CanvasRenderingContext2D = document.createElement('canvas').getContext('2d')!; + /** * Calculates max symbol height for provided font on canvas. * @doc-tags tricky,chart-core,canvas @@ -15,17 +19,15 @@ const symbolHeightCache: Partial> = {}; * @param {CanvasRenderingContext2D} ctx - The CanvasRenderingContext2D to use for the calculation. * @returns {number} - The height of the symbol. */ -export function calculateSymbolHeight(font: string, ctx: CanvasRenderingContext2D): number { +export function calculateSymbolHeight(font: string): number { let result = symbolHeightCache[font]; if (result !== undefined) { return result; } // Capital "M" width is very close to height, 90% approximation - ctx.save(); - ctx.font = font; - result = ctx.measureText('M').width; + ctxForMeasure.font = font; + result = ctxForMeasure.measureText('M').width; symbolHeightCache[font] = result; - ctx.restore(); return result; } @@ -41,19 +43,15 @@ const textWidthCache: Map = new Map(); /** * Calculates the width of a given text using the provided font and canvas rendering context. * @param {string} text - The text to calculate the width of. - * @param {CanvasRenderingContext2D} ctx - The canvas rendering context to use for measuring the text width. * @param {string} font - The font to use for measuring the text width. * @returns {number} - The width of the text in pixels. */ -export function calculateTextWidth(text: string, ctx: CanvasRenderingContext2D, font: string): number { +export function calculateTextWidth(text: string, font: string): number { const key = font + text; let result = textWidthCache.get(key); if (!result) { - ctx.save(); - ctx.font = font; - result = ctx.measureText(text).width; - textWidthCache.set(key, result); - ctx.restore(); + ctxForMeasure.font = font; + result = ctxForMeasure.measureText(text).width; } return result; } diff --git a/src/chart/utils/mutex.ts b/src/chart/utils/mutex.ts new file mode 100644 index 00000000..4ad9aefe --- /dev/null +++ b/src/chart/utils/mutex.ts @@ -0,0 +1,40 @@ +import { constVoid } from "./function.utils"; + + +export interface Mutex { + lock: () => void; + unlock: () => void; + current: Promise; + locked: boolean; + calculateSafe: (fn: () => T) => Promise; +} + +export const createMutex = (): Mutex => { + const mutex: Mutex = { + locked: false, + unlock: constVoid, + current: Promise.resolve(), + lock: () => { + if (mutex.locked) { + return; + } + mutex.current = new Promise(resolve => { + mutex.unlock = () => { + mutex.locked = false; + resolve(); + }; + }); + mutex.locked = true; + }, + calculateSafe: async fn => { + while (mutex.locked) { + await mutex.current; + } + mutex.lock(); + const result = await fn(); + mutex.unlock(); + return result; + } + }; + return mutex; +}; \ No newline at end of file diff --git a/src/index.dev.ts b/src/index.dev.ts index acfaf04c..eb2d3566 100644 --- a/src/index.dev.ts +++ b/src/index.dev.ts @@ -3,8 +3,7 @@ * This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. * If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { createChart } from './index'; -import ChartBootstrap from './chart/bootstrap'; +import { Chart, createChart } from './index'; import { generateCandlesDataTS } from './chart/utils/candles-generator-ts.utils'; const container = document.createElement('div'); @@ -20,7 +19,7 @@ container.style.height = '100vh'; container.style.overflow = 'hidden'; // DXChart init -const chart: ChartBootstrap = createChart(container); +const chart: Chart = createChart(container); const candles = generateCandlesDataTS({ quantity: 1000, withVolume: true }); chart.setData({ candles }); diff --git a/tsconfig.json b/tsconfig.json index 5f017790..069b3c55 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,7 +12,7 @@ "allowSyntheticDefaultImports": true, "sourceMap": false, "target": "es6", - "lib": ["dom", "ES2016"], + "lib": ["dom", "ES2017"], "declaration": true, "skipLibCheck": true, "isolatedModules": true, diff --git a/webpack.dev.config.js b/webpack.dev.config.js index c5541524..cb250e1d 100644 --- a/webpack.dev.config.js +++ b/webpack.dev.config.js @@ -7,9 +7,10 @@ module.exports = env => { mode: 'development', entry: { index: './src/index.dev.ts', + 'offscreen-worker': './src/chart/canvas/offscreen/offscreen-worker.js', }, output: { - filename: `./test/[name].js`, + filename: `./[name].js`, path: path.resolve(__dirname, 'dist'), }, resolve: { @@ -23,6 +24,7 @@ module.exports = env => { historyApiFallback: true, open: false, liveReload: false, + headers: { 'Cross-Origin-Opener-Policy': 'same-origin', 'Cross-Origin-Embedder-Policy': 'credentialless' }, }, module: { rules: [ @@ -32,7 +34,7 @@ module.exports = env => { include: path.resolve('./src'), loader: 'esbuild-loader', options: { - target: 'es6', + target: 'es2020', }, }, ], diff --git a/yarn.lock b/yarn.lock index d67fc61e..9f92d092 100644 --- a/yarn.lock +++ b/yarn.lock @@ -646,6 +646,7 @@ __metadata: "@typescript-eslint/parser": ^5.59.8 chai: ^4.3.7 color: ^4.2.3 + comlink: 4.4.1 commander: ^10.0.1 date-fns: ^2.30.0 esbuild: ^0.17.19 @@ -3451,6 +3452,13 @@ __metadata: languageName: node linkType: hard +"comlink@npm:4.4.1": + version: 4.4.1 + resolution: "comlink@npm:4.4.1" + checksum: 16d58a8f590087fc45432e31d6c138308dfd4b75b89aec0b7f7bb97ad33d810381bd2b1e608a1fb2cf05979af9cbfcdcaf1715996d5fcf77aeb013b6da3260af + languageName: node + linkType: hard + "commander@npm:^10.0.1": version: 10.0.1 resolution: "commander@npm:10.0.1"