From 58c45797dd4a9ebeac09c16a761aecdb310fcc9c Mon Sep 17 00:00:00 2001 From: Mark Silverwood Date: Mon, 19 Jun 2023 18:52:51 +0100 Subject: [PATCH 01/11] added pixel rendering guide for plugin developers --- .../pixel-perfect-rendering/_category_.yml | 2 + .../plugins/pixel-perfect-rendering/index.md | 101 +++++++ .../widths/_category_.yml | 2 + .../widths/candlestick.md | 81 ++++++ .../pixel-perfect-rendering/widths/columns.md | 269 ++++++++++++++++++ .../widths/crosshair.md | 54 ++++ .../widths/full-bar-width.md | 63 ++++ 7 files changed, 572 insertions(+) create mode 100644 website/docs/plugins/pixel-perfect-rendering/_category_.yml create mode 100644 website/docs/plugins/pixel-perfect-rendering/index.md create mode 100644 website/docs/plugins/pixel-perfect-rendering/widths/_category_.yml create mode 100644 website/docs/plugins/pixel-perfect-rendering/widths/candlestick.md create mode 100644 website/docs/plugins/pixel-perfect-rendering/widths/columns.md create mode 100644 website/docs/plugins/pixel-perfect-rendering/widths/crosshair.md create mode 100644 website/docs/plugins/pixel-perfect-rendering/widths/full-bar-width.md diff --git a/website/docs/plugins/pixel-perfect-rendering/_category_.yml b/website/docs/plugins/pixel-perfect-rendering/_category_.yml new file mode 100644 index 0000000000..f5f99ef932 --- /dev/null +++ b/website/docs/plugins/pixel-perfect-rendering/_category_.yml @@ -0,0 +1,2 @@ +label: "Pixel Perfect Rendering" +position: 5 diff --git a/website/docs/plugins/pixel-perfect-rendering/index.md b/website/docs/plugins/pixel-perfect-rendering/index.md new file mode 100644 index 0000000000..d1017ac2de --- /dev/null +++ b/website/docs/plugins/pixel-perfect-rendering/index.md @@ -0,0 +1,101 @@ +--- +sidebar_position: 0 +sidebar_label: Pixel Perfect Rendering +pagination_title: Pixel Perfect Rendering +title: Best Practices for Pixel Perfect Rendering in Canvas Drawings +description: Best Practices for Pixel Perfect Rendering in Canvas Drawings when creating plugins for the Lightweight Charts +keywords: + - plugins + - extensions + - rendering + - canvas + - bitmap + - media + - pixels +pagination_prev: null +--- + +To achieve crisp pixel perfect rendering for your plugins, it is recommended that the canvas drawings are created using bitmap coordinates. The difference between media and bitmap coordinate spaces is discussed on the [Canvas Rendering Target](../canvas-rendering-target.md) page. **Essentially, all drawing actions should use integer positions and dimensions when on the bitmap coordinate space.** + +To ensure consistency between your plugins and the library's built-in logic for rendering points on the chart, use of the following calculation functions. + +:::info + +Variable names containing `media` refer to positions / dimensions specified using the media coordinate space (such as the x and y coordinates provided by the library to the renderers), and names containing `bitmap` refer to positions / dimensions on the bitmap coordinate space (actual device screen pixels). + +::: + +## Centered Shapes + +If you need to draw a shape which is centred on a position (for example a price or x coordinate) and has a desired width then you could use the `positionsLine` function presented below. This can be used for drawing a horizontal line at a specific price, or a vertical line aligned with the centre of series point. + +```typescript +interface BitmapPositionLength { + /** coordinate for use with a bitmap rendering scope */ + position: number; + /** length for use with a bitmap rendering scope */ + length: number; +} + +function centreOffset(lineBitmapWidth: number): number { + return Math.floor(lineBitmapWidth * 0.5); +} + +/** + * Calculates the bitmap position for an item with a desired length (height or width), and centred according to + * an position coordinate defined in media sizing. + * @param positionMedia - position coordinate for the bar (in media coordinates) + * @param pixelRatio - pixel ratio. Either horizontal for x positions, or vertical for y positions + * @param desiredWidthMedia - desired width (in media coordinates) + * @returns Position of of the start point and length dimension. + */ +export function positionsLine( + positionMedia: number, + pixelRatio: number, + desiredWidthMedia: number = 1, + widthIsBitmap?: boolean +): BitmapPositionLength { + const scaledPosition = Math.round(pixelRatio * positionMedia); + const lineBitmapWidth = widthIsBitmap + ? desiredWidthMedia + : Math.round(desiredWidthMedia * pixelRatio); + const offset = centreOffset(lineBitmapWidth); + const position = scaledPosition - offset; + return { position, length: lineBitmapWidth }; +} +``` + +## Dual Point Shapes + +If you need to draw a shape between two coordinates (for example, y coordinates for a high and low price) then you can use the `positionsBox` function as presented below. + +```typescript +/** + * Determines the bitmap position and length for a dimension of a shape to be drawn. + * @param position1Media - media coordinate for the first point + * @param position2Media - media coordinate for the second point + * @param pixelRatio - pixel ratio for the corresponding axis (vertical or horizontal) + * @returns Position of of the start point and length dimension. + */ +export function positionsBox( + position1Media: number, + position2Media: number, + pixelRatio: number +): BitmapPositionLength { + const scaledPosition1 = Math.round(pixelRatio * position1Media); + const scaledPosition2 = Math.round(pixelRatio * position2Media); + return { + position: Math.min(scaledPosition1, scaledPosition2), + length: Math.abs(scaledPosition2 - scaledPosition1) + 1, + }; +} +``` + +## Defaults Widths + +Please refer to the following pages for functions defining the default widths of shapes drawn by the library: + +- [Crosshair and Grid Lines](./widths/crosshair.md) +- [Candlesticks](./widths/candlestick.md) +- [Columns (Histogram)](./widths/columns.md) +- [Full Bar Width](./widths/full-bar-width.md) diff --git a/website/docs/plugins/pixel-perfect-rendering/widths/_category_.yml b/website/docs/plugins/pixel-perfect-rendering/widths/_category_.yml new file mode 100644 index 0000000000..9ccc1a42ca --- /dev/null +++ b/website/docs/plugins/pixel-perfect-rendering/widths/_category_.yml @@ -0,0 +1,2 @@ +label: "Default Widths" +position: 0 diff --git a/website/docs/plugins/pixel-perfect-rendering/widths/candlestick.md b/website/docs/plugins/pixel-perfect-rendering/widths/candlestick.md new file mode 100644 index 0000000000..43bd78c391 --- /dev/null +++ b/website/docs/plugins/pixel-perfect-rendering/widths/candlestick.md @@ -0,0 +1,81 @@ +--- +sidebar_position: 0 +sidebar_label: Candlesticks +pagination_title: Candlestick Widths +title: Candlestick Width Calculations +description: Describes the calculation for candlestick body widths +keywords: + - plugins + - extensions + - rendering + - canvas + - bitmap + - media + - pixels + - candlestick + - width +--- + +:::tip + +It is recommend that you first read the [Pixel Perfect Rendering](../index.md) page. + +::: + +The following functions can be used to get the calculated width that the library would use for a candlestick at a specific bar spacing and device pixel ratio. + +Below a bar spacing of 4, the library will attempt to use as large a width as possible without the possibility of overlapping, whilst above 4 then the width will start to trend towards an 80% width of the available space. + +:::warning + +It is expected that candles can overlap slightly at smaller bar spacings (more pronounced on lower resolution devices). This produces a more readable chart. If you need to ensure that bars can never overlap then rather use the widths for [Columns](./columns.md) or the [full bar width](./full-bar-width.md) calculation. + +::: + +```typescript +function optimalCandlestickWidth( + barSpacing: number, + pixelRatio: number +): number { + const barSpacingSpecialCaseFrom = 2.5; + const barSpacingSpecialCaseTo = 4; + const barSpacingSpecialCaseCoeff = 3; + if (barSpacing >= barSpacingSpecialCaseFrom && barSpacing <= barSpacingSpecialCaseTo) { + return Math.floor(barSpacingSpecialCaseCoeff * pixelRatio); + } + // coeff should be 1 on small barspacing and go to 0.8 while bar spacing grows + const barSpacingReducingCoeff = 0.2; + const coeff = + 1 - + (barSpacingReducingCoeff * + Math.atan( + Math.max(barSpacingSpecialCaseTo, barSpacing) - barSpacingSpecialCaseTo + )) / + (Math.PI * 0.5); + const res = Math.floor(barSpacing * coeff * pixelRatio); + const scaledBarSpacing = Math.floor(barSpacing * pixelRatio); + const optimal = Math.min(res, scaledBarSpacing); + return Math.max(Math.floor(pixelRatio), optimal); +} + +/** + * Calculates the candlestick width that the library would use for the current + * bar spacing. + * @param barSpacing bar spacing in media coordinates + * @param horizontalPixelRatio - horizontal pixel ratio + * @returns The width (in bitmap coordinates) that the chart would use to draw a candle body + */ +export function candlestickWidth( + barSpacing: number, + horizontalPixelRatio: number +): number { + let width = optimalCandlestickWidth(barSpacing, horizontalPixelRatio); + if (width >= 2) { + const wickWidth = Math.floor(horizontalPixelRatio); + if (wickWidth % 2 !== width % 2) { + width--; + } + } + return width; +} +``` diff --git a/website/docs/plugins/pixel-perfect-rendering/widths/columns.md b/website/docs/plugins/pixel-perfect-rendering/widths/columns.md new file mode 100644 index 0000000000..c2384f27de --- /dev/null +++ b/website/docs/plugins/pixel-perfect-rendering/widths/columns.md @@ -0,0 +1,269 @@ +--- +sidebar_position: 0 +sidebar_label: Columns +pagination_title: Histogram Column Widths +title: Histogram Column Width Calculations +description: Describes the calculation for histogram column widths +keywords: + - plugins + - extensions + - rendering + - canvas + - bitmap + - media + - pixels + - histogram + - column + - width +--- + +:::tip + +It is recommend that you first read the [Pixel Perfect Rendering](../index.md) page. + +::: + +The following functions can be used to get the calculated width that the library would use for a histogram column at a specific bar spacing and device pixel ratio. + +You can use the `calculateColumnPositionsInPlace` function instead of the `calculateColumnPositions` function to perform the calculation on an existing array of items without needing to create additional arrays (which is more efficient). It is recommended that you memoize the majority of the calculations below to improve the rendering performance. + +```typescript +const alignToMinimalWidthLimit = 4; +const showSpacingMinimalBarWidth = 1; + +/** + * Spacing gap between columns. + * @param barSpacingMedia - spacing between bars (media coordinate) + * @param horizontalPixelRatio - horizontal pixel ratio + * @returns Spacing gap between columns (in Bitmap coordinates) + */ +function columnSpacing(barSpacingMedia: number, horizontalPixelRatio: number) { + return Math.ceil(barSpacingMedia * horizontalPixelRatio) <= + showSpacingMinimalBarWidth + ? 0 + : Math.max(1, Math.floor(horizontalPixelRatio)); +} + +/** + * Desired width for columns. This may not be the final width because + * it may be adjusted later to ensure all columns on screen have a + * consistent width and gap. + * @param barSpacingMedia - spacing between bars (media coordinate) + * @param horizontalPixelRatio - horizontal pixel ratio + * @param spacing - Spacing gap between columns (in Bitmap coordinates). (optional, provide if you have already calculated it) + * @returns Desired width for column bars (in Bitmap coordinates) + */ +function desiredColumnWidth( + barSpacingMedia: number, + horizontalPixelRatio: number, + spacing?: number +) { + return ( + Math.round(barSpacingMedia * horizontalPixelRatio) - + (spacing ?? columnSpacing(barSpacingMedia, horizontalPixelRatio)) + ); +} + +interface ColumnCommon { + /** Spacing gap between columns */ + spacing: number; + /** Shift columns left by one pixel */ + shiftLeft: boolean; + /** Half width of a column */ + columnHalfWidthBitmap: number; + /** horizontal pixel ratio */ + horizontalPixelRatio: number; +} + +/** + * Calculated values which are common to all the columns on the screen, and + * are required to calculate the individual positions. + * @param barSpacingMedia - spacing between bars (media coordinate) + * @param horizontalPixelRatio - horizontal pixel ratio + * @returns calculated values for subsequent column calculations + */ +function columnCommon( + barSpacingMedia: number, + horizontalPixelRatio: number +): ColumnCommon { + const spacing = columnSpacing(barSpacingMedia, horizontalPixelRatio); + const columnWidthBitmap = desiredColumnWidth( + barSpacingMedia, + horizontalPixelRatio, + spacing + ); + const shiftLeft = columnWidthBitmap % 2 === 0; + const columnHalfWidthBitmap = (columnWidthBitmap - (shiftLeft ? 0 : 1)) / 2; + return { + spacing, + shiftLeft, + columnHalfWidthBitmap, + horizontalPixelRatio, + }; +} + +interface ColumnPosition { + left: number; + right: number; + shiftLeft: boolean; +} + +/** + * Calculate the position for a column. These values can be later adjusted + * by a second pass which corrects widths, and shifts columns. + * @param xMedia - column x position (center) in media coordinates + * @param columnData - precalculated common values (returned by `columnCommon`) + * @param previousPosition - result from this function for the previous bar. + * @returns initial column position + */ +function calculateColumnPosition( + xMedia: number, + columnData: ColumnCommon, + previousPosition: ColumnPosition | undefined +): ColumnPosition { + const xBitmapUnRounded = xMedia * columnData.horizontalPixelRatio; + const xBitmap = Math.round(xBitmapUnRounded); + const xPositions: ColumnPosition = { + left: xBitmap - columnData.columnHalfWidthBitmap, + right: + xBitmap + + columnData.columnHalfWidthBitmap - + (columnData.shiftLeft ? 1 : 0), + shiftLeft: xBitmap > xBitmapUnRounded, + }; + const expectedAlignmentShift = columnData.spacing + 1; + if (previousPosition) { + if (xPositions.left - previousPosition.right !== expectedAlignmentShift) { + // need to adjust alignment + if (previousPosition.shiftLeft) { + previousPosition.right = xPositions.left - expectedAlignmentShift; + } else { + xPositions.left = previousPosition.right + expectedAlignmentShift; + } + } + } + return xPositions; +} + +function fixPositionsAndReturnSmallestWidth( + positions: ColumnPosition[], + initialMinWidth: number +): number { + return positions.reduce((smallest: number, position: ColumnPosition) => { + if (position.right < position.left) { + position.right = position.left; + } + const width = position.right - position.left + 1; + return Math.min(smallest, width); + }, initialMinWidth); +} + +function fixAlignmentForNarrowColumns( + positions: ColumnPosition[], + minColumnWidth: number +) { + return positions.map((position: ColumnPosition) => { + const width = position.right - position.left + 1; + if (width <= minColumnWidth) return position; + if (position.shiftLeft) { + position.right -= 1; + } else { + position.left += 1; + } + return position; + }); +} + +/** + * Calculates the column positions and widths for the x positions. + * This function creates a new array. You may get faster performance using the + * `calculateColumnPositionsInPlace` function instead + * @param xMediaPositions - x positions for the bars in media coordinates + * @param barSpacingMedia - spacing between bars in media coordinates + * @param horizontalPixelRatio - horizontal pixel ratio + * @returns Positions for the columns + */ +export function calculateColumnPositions( + xMediaPositions: number[], + barSpacingMedia: number, + horizontalPixelRatio: number +): ColumnPosition[] { + const common = columnCommon(barSpacingMedia, horizontalPixelRatio); + const positions = new Array(xMediaPositions.length); + let previous: ColumnPosition | undefined = undefined; + for (let i = 0; i < xMediaPositions.length; i++) { + positions[i] = calculateColumnPosition( + xMediaPositions[i], + common, + previous + ); + previous = positions[i]; + } + const initialMinWidth = Math.ceil(barSpacingMedia * horizontalPixelRatio); + const minColumnWidth = fixPositionsAndReturnSmallestWidth( + positions, + initialMinWidth + ); + if (common.spacing > 0 && minColumnWidth < alignToMinimalWidthLimit) { + return fixAlignmentForNarrowColumns(positions, minColumnWidth); + } + return positions; +} + +export interface ColumnPositionItem { + x: number; + column?: ColumnPosition; +} + +/** + * Calculates the column positions and widths for bars using the existing the + * array of items. + * @param items - bar items which include an `x` property, and will be mutated to contain a column property + * @param barSpacingMedia - bar spacing in media coordinates + * @param horizontalPixelRatio - horizontal pixel ratio + * @param startIndex - start index for visible bars within the items array + * @param endIndex - end index for visible bars within the items array + */ +export function calculateColumnPositionsInPlace( + items: ColumnPositionItem[], + barSpacingMedia: number, + horizontalPixelRatio: number, + startIndex: number, + endIndex: number +): void { + const common = columnCommon(barSpacingMedia, horizontalPixelRatio); + let previous: ColumnPosition | undefined = undefined; + for (let i = startIndex; i < Math.min(endIndex, items.length); i++) { + items[i].column = calculateColumnPosition(items[i].x, common, previous); + previous = items[i].column; + } + const minColumnWidth = (items as ColumnPositionItem[]).reduce( + (smallest: number, item: ColumnPositionItem, index: number) => { + if (!item.column || index < startIndex || index > endIndex) + return smallest; + if (item.column.right < item.column.left) { + item.column.right = item.column.left; + } + const width = item.column.right - item.column.left + 1; + return Math.min(smallest, width); + }, + Math.ceil(barSpacingMedia * horizontalPixelRatio) + ); + if (common.spacing > 0 && minColumnWidth < alignToMinimalWidthLimit) { + (items as ColumnPositionItem[]).forEach( + (item: ColumnPositionItem, index: number) => { + if (!item.column || index < startIndex || index > endIndex) return; + const width = item.column.right - item.column.left + 1; + if (width <= minColumnWidth) return item; + if (item.column.shiftLeft) { + item.column.right -= 1; + } else { + item.column.left += 1; + } + return item.column; + } + ); + } +} + +``` diff --git a/website/docs/plugins/pixel-perfect-rendering/widths/crosshair.md b/website/docs/plugins/pixel-perfect-rendering/widths/crosshair.md new file mode 100644 index 0000000000..1099368268 --- /dev/null +++ b/website/docs/plugins/pixel-perfect-rendering/widths/crosshair.md @@ -0,0 +1,54 @@ +--- +sidebar_position: 0 +sidebar_label: Crosshair +pagination_title: Crosshair Widths +title: Crosshair and Grid Line Width Calculations +description: Describes the calculation for the crosshair line and grid line widths +keywords: + - plugins + - extensions + - rendering + - canvas + - bitmap + - media + - pixels + - crosshair + - grid + - line + - width +--- + +:::tip + +It is recommend that you first read the [Pixel Perfect Rendering](../index.md) page. + +::: + +The following functions can be used to get the calculated width that the library would use for a crosshair or grid line at a specific device pixel ratio. + +```typescript +/** + * Default grid / crosshair line width in Bitmap sizing + * @param horizontalPixelRatio - horizontal pixel ratio + * @returns default grid / crosshair line width in Bitmap sizing + */ +export function gridAndCrosshairBitmapWidth( + horizontalPixelRatio: number +): number { + return Math.max(1, Math.floor(horizontalPixelRatio)); +} + +/** + * Default grid / crosshair line width in Media sizing + * @param horizontalPixelRatio - horizontal pixel ratio + * @returns default grid / crosshair line width in Media sizing + */ +export function gridAndCrosshairMediaWidth( + horizontalPixelRatio: number +): number { + return ( + gridAndCrosshairBitmapWidth(horizontalPixelRatio) / horizontalPixelRatio + ); +} + +``` diff --git a/website/docs/plugins/pixel-perfect-rendering/widths/full-bar-width.md b/website/docs/plugins/pixel-perfect-rendering/widths/full-bar-width.md new file mode 100644 index 0000000000..414f67d5f8 --- /dev/null +++ b/website/docs/plugins/pixel-perfect-rendering/widths/full-bar-width.md @@ -0,0 +1,63 @@ +--- +sidebar_position: 0 +sidebar_label: Full Bar Width +pagination_title: Full Bar Width +title: Full Bar Width Calculations +description: Describes the calculation for full bar widths +keywords: + - plugins + - extensions + - rendering + - canvas + - bitmap + - media + - pixels + - histogram + - column + - width +--- + +:::tip + +It is recommend that you first read the [Pixel Perfect Rendering](../index.md) page. + +::: + +The following functions can be used to get the calculated width that the library would use for the full width of a bar (data point) at a specific bar spacing and device pixel ratio. This can be used when you would like to use the full width available for each data point on the x axis, and don't want any gaps to be visible. + +```typescript +interface BitmapPositionLength { + /** coordinate for use with a bitmap rendering scope */ + position: number; + /** length for use with a bitmap rendering scope */ + length: number; +} + +/** + * Calculates the position and width which will completely full the space for the bar. + * Useful if you want to draw something that will not have any gaps between surrounding bars. + * @param xMedia - x coordinate of the bar defined in media sizing + * @param halfBarSpacingMedia - half the width of the current barSpacing (un-rounded) + * @param horizontalPixelRatio - horizontal pixel ratio + * @returns position and width which will completely full the space for the bar + */ +export function fullBarWidth( + xMedia: number, + halfBarSpacingMedia: number, + horizontalPixelRatio: number +): BitmapPositionLength { + const fullWidthLeftMedia = xMedia - halfBarSpacingMedia; + const fullWidthRightMedia = xMedia + halfBarSpacingMedia; + const fullWidthLeftBitmap = Math.round( + fullWidthLeftMedia * horizontalPixelRatio + ); + const fullWidthRightBitmap = Math.round( + fullWidthRightMedia * horizontalPixelRatio + ); + const fullWidthBitmap = fullWidthRightBitmap - fullWidthLeftBitmap; + return { + position: fullWidthLeftBitmap, + length: fullWidthBitmap, + }; +} +``` From efdd8764e84472c92d85e5b9a46e0097deb6ca56 Mon Sep 17 00:00:00 2001 From: Mark Silverwood Date: Thu, 29 Jun 2023 15:28:23 +0100 Subject: [PATCH 02/11] add subscribeDblClick handler to the IChartApi --- .size-limit.js | 8 ++++---- src/api/chart-api.ts | 19 +++++++++++++++++++ src/api/ichart-api.ts | 30 ++++++++++++++++++++++++++++++ src/gui/chart-widget.ts | 17 +++++++++++++++++ src/gui/pane-widget.ts | 24 ++++++++++++++++++++++++ 5 files changed, 94 insertions(+), 4 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index 7512fb3e78..c22ddb4df7 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -4,21 +4,21 @@ module.exports = [ { name: 'CJS', path: 'dist/lightweight-charts.production.cjs', - limit: '46.94 KB', + limit: '47.05 KB', }, { name: 'ESM', path: 'dist/lightweight-charts.production.mjs', - limit: '46.86 KB', + limit: '46.97 KB', }, { name: 'Standalone-ESM', path: 'dist/lightweight-charts.standalone.production.mjs', - limit: '48.56 KB', + limit: '48.66 KB', }, { name: 'Standalone', path: 'dist/lightweight-charts.standalone.production.js', - limit: '48.61 KB', + limit: '48.70 KB', }, ]; diff --git a/src/api/chart-api.ts b/src/api/chart-api.ts index 2729a00e0d..e90d11a269 100644 --- a/src/api/chart-api.ts +++ b/src/api/chart-api.ts @@ -119,6 +119,7 @@ export class ChartApi implements IChartApi, DataUpdatesConsumer { private readonly _seriesMapReversed: Map> = new Map(); private readonly _clickedDelegate: Delegate = new Delegate(); + private readonly _dblClickedDelegate: Delegate = new Delegate(); private readonly _crosshairMovedDelegate: Delegate = new Delegate(); private readonly _timeScaleApi: TimeScaleApi; @@ -138,6 +139,14 @@ export class ChartApi implements IChartApi, DataUpdatesConsumer { }, this ); + this._chartWidget.dblClicked().subscribe( + (paramSupplier: MouseEventParamsImplSupplier) => { + if (this._dblClickedDelegate.hasListeners()) { + this._dblClickedDelegate.fire(this._convertMouseParams(paramSupplier())); + } + }, + this + ); this._chartWidget.crosshairMoved().subscribe( (paramSupplier: MouseEventParamsImplSupplier) => { if (this._crosshairMovedDelegate.hasListeners()) { @@ -153,6 +162,7 @@ export class ChartApi implements IChartApi, DataUpdatesConsumer { public remove(): void { this._chartWidget.clicked().unsubscribeAll(this); + this._chartWidget.dblClicked().unsubscribeAll(this); this._chartWidget.crosshairMoved().unsubscribeAll(this); this._timeScaleApi.destroy(); @@ -162,6 +172,7 @@ export class ChartApi implements IChartApi, DataUpdatesConsumer { this._seriesMapReversed.clear(); this._clickedDelegate.destroy(); + this._dblClickedDelegate.destroy(); this._crosshairMovedDelegate.destroy(); this._dataLayer.destroy(); } @@ -252,6 +263,14 @@ export class ChartApi implements IChartApi, DataUpdatesConsumer { this._clickedDelegate.unsubscribe(handler); } + public subscribeDblClick(handler: MouseEventHandler): void { + this._dblClickedDelegate.subscribe(handler); + } + + public unsubscribeDblClick(handler: MouseEventHandler): void { + this._dblClickedDelegate.unsubscribe(handler); + } + public subscribeCrosshairMove(handler: MouseEventHandler): void { this._crosshairMovedDelegate.subscribe(handler); } diff --git a/src/api/ichart-api.ts b/src/api/ichart-api.ts index a16385c1cc..551cf88e47 100644 --- a/src/api/ichart-api.ts +++ b/src/api/ichart-api.ts @@ -222,6 +222,36 @@ export interface IChartApi { */ unsubscribeClick(handler: MouseEventHandler): void; + /** + * Subscribe to the chart double-click event. + * + * @param handler - Handler to be called on mouse double-click. + * @example + * ```js + * function myDblClickHandler(param) { + * if (!param.point) { + * return; + * } + * + * console.log(`Double Click at ${param.point.x}, ${param.point.y}. The time is ${param.time}.`); + * } + * + * chart.subscribeDblClick(myDblClickHandler); + * ``` + */ + subscribeDblClick(handler: MouseEventHandler): void; + + /** + * Unsubscribe a handler that was previously subscribed using {@link subscribeDblClick}. + * + * @param handler - Previously subscribed handler + * @example + * ```js + * chart.unsubscribeDblClick(myDblClickHandler); + * ``` + */ + unsubscribeDblClick(handler: MouseEventHandler): void; + /** * Subscribe to the crosshair move event. * diff --git a/src/gui/chart-widget.ts b/src/gui/chart-widget.ts index da800c45b5..5c78d5068a 100644 --- a/src/gui/chart-widget.ts +++ b/src/gui/chart-widget.ts @@ -59,6 +59,7 @@ export class ChartWidget implements IDestroyable { private _invalidateMask: InvalidateMask | null = null; private _drawPlanned: boolean = false; private _clicked: Delegate = new Delegate(); + private _dblClicked: Delegate = new Delegate(); private _crosshairMoved: Delegate = new Delegate(); private _onWheelBound: (event: WheelEvent) => void; private _observer: ResizeObserver | null = null; @@ -151,6 +152,7 @@ export class ChartWidget implements IDestroyable { for (const paneWidget of this._paneWidgets) { this._tableElement.removeChild(paneWidget.getElement()); paneWidget.clicked().unsubscribeAll(this); + paneWidget.dblClicked().unsubscribeAll(this); paneWidget.destroy(); } this._paneWidgets = []; @@ -168,6 +170,7 @@ export class ChartWidget implements IDestroyable { this._crosshairMoved.destroy(); this._clicked.destroy(); + this._dblClicked.destroy(); this._uninstallObserver(); } @@ -234,6 +237,10 @@ export class ChartWidget implements IDestroyable { return this._clicked; } + public dblClicked(): ISubscription { + return this._dblClicked; + } + public crosshairMoved(): ISubscription { return this._crosshairMoved; } @@ -684,6 +691,7 @@ export class ChartWidget implements IDestroyable { const paneWidget = ensureDefined(this._paneWidgets.pop()); this._tableElement.removeChild(paneWidget.getElement()); paneWidget.clicked().unsubscribeAll(this); + paneWidget.dblClicked().unsubscribeAll(this); paneWidget.destroy(); // const paneSeparator = this._paneSeparators.pop(); @@ -696,6 +704,7 @@ export class ChartWidget implements IDestroyable { for (let i = actualPaneWidgetsCount; i < targetPaneWidgetsCount; i++) { const paneWidget = new PaneWidget(this, panes[i]); paneWidget.clicked().subscribe(this._onPaneWidgetClicked.bind(this), this); + paneWidget.dblClicked().subscribe(this._onPaneWidgetDblClicked.bind(this), this); this._paneWidgets.push(paneWidget); @@ -777,6 +786,14 @@ export class ChartWidget implements IDestroyable { this._clicked.fire(() => this._getMouseEventParamsImpl(time, point, event)); } + private _onPaneWidgetDblClicked( + time: TimePointIndex | null, + point: Point | null, + event: TouchMouseEventData + ): void { + this._dblClicked.fire(() => this._getMouseEventParamsImpl(time, point, event)); + } + private _onPaneWidgetCrosshairMoved( time: TimePointIndex | null, point: Point | null, diff --git a/src/gui/pane-widget.ts b/src/gui/pane-widget.ts index 32820b6dd0..a31244000f 100644 --- a/src/gui/pane-widget.ts +++ b/src/gui/pane-widget.ts @@ -76,6 +76,7 @@ export class PaneWidget implements IDestroyable, MouseEventHandlers { private _startScrollingPos: StartScrollPosition | null = null; private _isScrolling: boolean = false; private _clicked: Delegate = new Delegate(); + private _dblClicked: Delegate = new Delegate(); private _prevPinchScale: number = 0; private _longTap: boolean = false; private _startTrackPoint: Point | null = null; @@ -266,6 +267,17 @@ export class PaneWidget implements IDestroyable, MouseEventHandlers { this._fireClickedDelegate(event); } + public mouseDoubleClickEvent(event: MouseEventHandlerMouseEvent | MouseEventHandlerTouchEvent): void { + if (this._state === null) { + return; + } + this._fireDblClickedDelegate(event); + } + + public doubleTapEvent(event: MouseEventHandlerTouchEvent): void { + this.mouseDoubleClickEvent(event); + } + public pressedMouseMoveEvent(event: MouseEventHandlerMouseEvent): void { this._onMouseEvent(); this._pressedMouseTouchMoveEvent(event); @@ -313,6 +325,10 @@ export class PaneWidget implements IDestroyable, MouseEventHandlers { return this._clicked; } + public dblClicked(): ISubscription { + return this._dblClicked; + } + public pinchStartEvent(): void { this._prevPinchScale = 1; this._model().stopTimeScaleAnimation(); @@ -508,6 +524,14 @@ export class PaneWidget implements IDestroyable, MouseEventHandlers { } } + private _fireDblClickedDelegate(event: MouseEventHandlerEventBase): void { + const x = event.localX; + const y = event.localY; + if (this._dblClicked.hasListeners()) { + this._dblClicked.fire(this._model().timeScale().coordinateToIndex(x), { x, y }, event); + } + } + private _drawBackground({ context: ctx, bitmapSize }: BitmapCoordinatesRenderingScope): void { const { width, height } = bitmapSize; const model = this._model(); From 1eab8a7b3a340e41dd3e3fa085c26372b40360e8 Mon Sep 17 00:00:00 2001 From: Mark Silverwood Date: Thu, 29 Jun 2023 15:35:50 +0100 Subject: [PATCH 03/11] refactored to be slightly more DRY --- src/gui/pane-widget.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/gui/pane-widget.ts b/src/gui/pane-widget.ts index a31244000f..78a40cccdb 100644 --- a/src/gui/pane-widget.ts +++ b/src/gui/pane-widget.ts @@ -271,7 +271,7 @@ export class PaneWidget implements IDestroyable, MouseEventHandlers { if (this._state === null) { return; } - this._fireDblClickedDelegate(event); + this._fireMouseClickDelegate(this._dblClicked, event); } public doubleTapEvent(event: MouseEventHandlerTouchEvent): void { @@ -517,18 +517,14 @@ export class PaneWidget implements IDestroyable, MouseEventHandlers { } private _fireClickedDelegate(event: MouseEventHandlerEventBase): void { - const x = event.localX; - const y = event.localY; - if (this._clicked.hasListeners()) { - this._clicked.fire(this._model().timeScale().coordinateToIndex(x), { x, y }, event); - } + this._fireMouseClickDelegate(this._clicked, event); } - private _fireDblClickedDelegate(event: MouseEventHandlerEventBase): void { + private _fireMouseClickDelegate(delegate: Delegate, event: MouseEventHandlerEventBase): void { const x = event.localX; const y = event.localY; - if (this._dblClicked.hasListeners()) { - this._dblClicked.fire(this._model().timeScale().coordinateToIndex(x), { x, y }, event); + if (delegate.hasListeners()) { + delegate.fire(this._model().timeScale().coordinateToIndex(x), { x, y }, event); } } From bdb363d566130b73bb8ea461255555598129b0d7 Mon Sep 17 00:00:00 2001 From: Mark Silverwood Date: Thu, 29 Jun 2023 15:49:25 +0100 Subject: [PATCH 04/11] add interaction test case for double click --- tests/e2e/helpers/perform-interactions.ts | 5 +- .../test-cases/mouse/double-click-pane.js | 78 +++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/interactions/test-cases/mouse/double-click-pane.js diff --git a/tests/e2e/helpers/perform-interactions.ts b/tests/e2e/helpers/perform-interactions.ts index 195baa15f2..8776533c54 100644 --- a/tests/e2e/helpers/perform-interactions.ts +++ b/tests/e2e/helpers/perform-interactions.ts @@ -6,6 +6,7 @@ import { doVerticalDrag, } from './mouse-drag-actions'; import { doMouseScroll } from './mouse-scroll-actions'; +import { pageTimeout } from './page-timeout'; import { doLongTouch, doPinchZoomTouch, doSwipeTouch } from './touch-actions'; import { doZoomInZoomOut } from './zoom-action'; @@ -84,7 +85,9 @@ async function performAction( await target.click({ button: 'left' }); break; case 'doubleClick': - await target.click({ button: 'left', clickCount: 2 }); + await target.click({ button: 'left' }); + await pageTimeout(page, 200); + await target.click({ button: 'left' }); break; case 'outsideClick': { diff --git a/tests/e2e/interactions/test-cases/mouse/double-click-pane.js b/tests/e2e/interactions/test-cases/mouse/double-click-pane.js new file mode 100644 index 0000000000..2ccc7aaa59 --- /dev/null +++ b/tests/e2e/interactions/test-cases/mouse/double-click-pane.js @@ -0,0 +1,78 @@ +function generateData() { + const res = []; + const time = new Date(Date.UTC(2018, 0, 1, 0, 0, 0, 0)); + for (let i = 0; i < 500; ++i) { + res.push({ + time: time.getTime() / 1000, + value: i, + }); + + time.setUTCDate(time.getUTCDate() + 1); + } + return res; +} + +function initialInteractionsToPerform() { + return [{ action: 'doubleClick', target: 'pane' }]; +} + +function finalInteractionsToPerform() { + return []; +} + +let chart; +let clickCount = 0; +let dblClickCount = 0; + +function beforeInteractions(container) { + chart = LightweightCharts.createChart(container); + + const mainSeries = chart.addLineSeries(); + + const mainSeriesData = generateData(); + mainSeries.setData(mainSeriesData); + + chart.subscribeDblClick(mouseParams => { + if (!mouseParams) { + return; + } + dblClickCount += 1; + }); + chart.subscribeClick(mouseParams => { + if (!mouseParams) { + return; + } + clickCount += 1; + }); + + return new Promise(resolve => { + requestAnimationFrame(() => { + resolve(); + }); + }); +} + +function afterInitialInteractions() { + return new Promise(resolve => { + requestAnimationFrame(resolve); + }); +} + +function afterFinalInteractions() { + if (clickCount < 1) { + throw new Error('Expected click event handler to be evoked.'); + } + if (dblClickCount < 1) { + throw new Error('Expected double click event handler to be evoked.'); + } + if (clickCount > 1) { + throw new Error('Expected click event handler to be evoked only once.'); + } + if (dblClickCount > 1) { + throw new Error( + 'Expected double click event handler to be evoked only once.' + ); + } + + return Promise.resolve(); +} From 7ece2ea8715d2b6b9c9fdd8dc309bca1dfa92be5 Mon Sep 17 00:00:00 2001 From: Mark Silverwood Date: Thu, 29 Jun 2023 16:55:30 +0100 Subject: [PATCH 05/11] fix percentage mode price scale bug --- .size-limit.js | 8 ++++---- src/model/price-range-impl.ts | 13 +++++++++++-- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index 7512fb3e78..6ffa48b19b 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -4,21 +4,21 @@ module.exports = [ { name: 'CJS', path: 'dist/lightweight-charts.production.cjs', - limit: '46.94 KB', + limit: '46.96 KB', }, { name: 'ESM', path: 'dist/lightweight-charts.production.mjs', - limit: '46.86 KB', + limit: '46.89 KB', }, { name: 'Standalone-ESM', path: 'dist/lightweight-charts.standalone.production.mjs', - limit: '48.56 KB', + limit: '48.58 KB', }, { name: 'Standalone', path: 'dist/lightweight-charts.standalone.production.js', - limit: '48.61 KB', + limit: '48.63 KB', }, ]; diff --git a/src/model/price-range-impl.ts b/src/model/price-range-impl.ts index 539e7ed3b6..542e2b6792 100644 --- a/src/model/price-range-impl.ts +++ b/src/model/price-range-impl.ts @@ -2,6 +2,15 @@ import { isNumber } from '../helpers/strict-type-checks'; import { PriceRange } from './series-options'; +function computeFiniteResult( + values: number[], + method: (...values: number[]) => number, + fallback: number +): number { + const result = method(...values.filter(Number.isFinite)); + return Number.isFinite(result) ? result : fallback; +} + export class PriceRangeImpl { private _minValue: number; private _maxValue!: number; @@ -43,8 +52,8 @@ export class PriceRangeImpl { return this; } return new PriceRangeImpl( - Math.min(this.minValue(), anotherRange.minValue()), - Math.max(this.maxValue(), anotherRange.maxValue()) + computeFiniteResult([this.minValue(), anotherRange.minValue()], Math.min, -Infinity), + computeFiniteResult([this.maxValue(), anotherRange.maxValue()], Math.max, Infinity) ); } From b8afa6a4b05f4cfb1469bfce959d2cedf6b240a2 Mon Sep 17 00:00:00 2001 From: Mark Silverwood Date: Thu, 29 Jun 2023 16:55:57 +0100 Subject: [PATCH 06/11] add graphics e2e test case --- ...percentage-first-value-invisible-series.js | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 tests/e2e/graphics/test-cases/price-scale/percentage-first-value-invisible-series.js diff --git a/tests/e2e/graphics/test-cases/price-scale/percentage-first-value-invisible-series.js b/tests/e2e/graphics/test-cases/price-scale/percentage-first-value-invisible-series.js new file mode 100644 index 0000000000..022c63c1f5 --- /dev/null +++ b/tests/e2e/graphics/test-cases/price-scale/percentage-first-value-invisible-series.js @@ -0,0 +1,39 @@ +function runTestCase(container) { + const chartOptions = { + rightPriceScale: { + borderVisible: false, + mode: 2, + }, + layout: { + textColor: 'black', + background: { type: 'solid', color: 'white' }, + }, + }; + const chart = (window.chart = LightweightCharts.createChart( + container, + chartOptions + )); + + /* + * We expect the blue series to NOT be visible + * and the red series to BE visible + */ + + const series1 = chart.addLineSeries({ + color: '#2962FF', + }); + series1.setData([ + { time: 1522033200, value: 0 }, + { time: 1529895600, value: -3 }, + { time: 1537758000, value: 3 }, + ]); + const series2 = chart.addLineSeries({ + color: '#FF2962', + }); + series2.setData([ + { time: 1522033200, value: 1 }, + { time: 1529895600, value: -1 }, + { time: 1537758000, value: 2 }, + ]); + chart.timeScale().fitContent(); +} From bf08fb135ad53e79fb1af759918c6f72a4acff54 Mon Sep 17 00:00:00 2001 From: Mark Silverwood Date: Thu, 29 Jun 2023 17:20:06 +0100 Subject: [PATCH 07/11] avoid using unneeded arrays --- .size-limit.js | 8 ++++---- src/model/price-range-impl.ts | 19 ++++++++++++++----- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index 6ffa48b19b..243d79f1e3 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -4,21 +4,21 @@ module.exports = [ { name: 'CJS', path: 'dist/lightweight-charts.production.cjs', - limit: '46.96 KB', + limit: '46.97 KB', }, { name: 'ESM', path: 'dist/lightweight-charts.production.mjs', - limit: '46.89 KB', + limit: '46.90 KB', }, { name: 'Standalone-ESM', path: 'dist/lightweight-charts.standalone.production.mjs', - limit: '48.58 KB', + limit: '48.59 KB', }, { name: 'Standalone', path: 'dist/lightweight-charts.standalone.production.js', - limit: '48.63 KB', + limit: '48.64 KB', }, ]; diff --git a/src/model/price-range-impl.ts b/src/model/price-range-impl.ts index 542e2b6792..c746f1a380 100644 --- a/src/model/price-range-impl.ts +++ b/src/model/price-range-impl.ts @@ -2,13 +2,22 @@ import { isNumber } from '../helpers/strict-type-checks'; import { PriceRange } from './series-options'; +function ensureFiniteWithFallback(value: number, fallback: number): number { + return Number.isFinite(value) ? value : fallback; +} + function computeFiniteResult( - values: number[], method: (...values: number[]) => number, + valueOne: number, + valueTwo: number, fallback: number ): number { - const result = method(...values.filter(Number.isFinite)); - return Number.isFinite(result) ? result : fallback; + const firstFinite = Number.isFinite(valueOne); + return firstFinite && Number.isFinite(valueTwo) + ? ensureFiniteWithFallback(method(valueOne, valueTwo), fallback) + : firstFinite + ? valueOne + : valueTwo; } export class PriceRangeImpl { @@ -52,8 +61,8 @@ export class PriceRangeImpl { return this; } return new PriceRangeImpl( - computeFiniteResult([this.minValue(), anotherRange.minValue()], Math.min, -Infinity), - computeFiniteResult([this.maxValue(), anotherRange.maxValue()], Math.max, Infinity) + computeFiniteResult(Math.min, this.minValue(), anotherRange.minValue(), -Infinity), + computeFiniteResult(Math.max, this.maxValue(), anotherRange.maxValue(), Infinity) ); } From 57e030f87c969e41bdc1c5d39870d2a00e7c5a32 Mon Sep 17 00:00:00 2001 From: Mark Silverwood Date: Thu, 29 Jun 2023 20:33:39 +0100 Subject: [PATCH 08/11] improve / fix computeFiniteResult method Whoops... the function now does what it says it does, even though the old logic was actually fine for the current requirement --- src/model/price-range-impl.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/model/price-range-impl.ts b/src/model/price-range-impl.ts index c746f1a380..e11ccb31b5 100644 --- a/src/model/price-range-impl.ts +++ b/src/model/price-range-impl.ts @@ -14,10 +14,10 @@ function computeFiniteResult( ): number { const firstFinite = Number.isFinite(valueOne); return firstFinite && Number.isFinite(valueTwo) - ? ensureFiniteWithFallback(method(valueOne, valueTwo), fallback) + ? method(valueOne, valueTwo) : firstFinite ? valueOne - : valueTwo; + : ensureFiniteWithFallback(valueTwo, fallback); } export class PriceRangeImpl { From 54dd023dd44884d013af04fbb0d28a52782eff2f Mon Sep 17 00:00:00 2001 From: Mark Silverwood Date: Mon, 3 Jul 2023 11:40:43 +0100 Subject: [PATCH 09/11] apply suggested changes to computeFiniteResult --- src/model/price-range-impl.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/model/price-range-impl.ts b/src/model/price-range-impl.ts index e11ccb31b5..04e9b13e77 100644 --- a/src/model/price-range-impl.ts +++ b/src/model/price-range-impl.ts @@ -2,10 +2,6 @@ import { isNumber } from '../helpers/strict-type-checks'; import { PriceRange } from './series-options'; -function ensureFiniteWithFallback(value: number, fallback: number): number { - return Number.isFinite(value) ? value : fallback; -} - function computeFiniteResult( method: (...values: number[]) => number, valueOne: number, @@ -13,11 +9,13 @@ function computeFiniteResult( fallback: number ): number { const firstFinite = Number.isFinite(valueOne); - return firstFinite && Number.isFinite(valueTwo) - ? method(valueOne, valueTwo) - : firstFinite - ? valueOne - : ensureFiniteWithFallback(valueTwo, fallback); + const secondFinite = Number.isFinite(valueTwo); + + if (firstFinite && secondFinite) { + return method(valueOne, valueTwo); + } + + return !firstFinite && !secondFinite ? fallback : (firstFinite ? valueOne : valueTwo); } export class PriceRangeImpl { From 93abae8c3d0e5f6f4a9878595a8738a8dba15320 Mon Sep 17 00:00:00 2001 From: Mark Silverwood Date: Mon, 17 Jul 2023 11:42:45 +0100 Subject: [PATCH 10/11] Apply typo fixes from code review Co-authored-by: Romain Francois --- website/docs/plugins/pixel-perfect-rendering/index.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/website/docs/plugins/pixel-perfect-rendering/index.md b/website/docs/plugins/pixel-perfect-rendering/index.md index d1017ac2de..8d6834c0ac 100644 --- a/website/docs/plugins/pixel-perfect-rendering/index.md +++ b/website/docs/plugins/pixel-perfect-rendering/index.md @@ -43,11 +43,11 @@ function centreOffset(lineBitmapWidth: number): number { /** * Calculates the bitmap position for an item with a desired length (height or width), and centred according to - * an position coordinate defined in media sizing. + * a position coordinate defined in media sizing. * @param positionMedia - position coordinate for the bar (in media coordinates) * @param pixelRatio - pixel ratio. Either horizontal for x positions, or vertical for y positions * @param desiredWidthMedia - desired width (in media coordinates) - * @returns Position of of the start point and length dimension. + * @returns Position of the start point and length dimension. */ export function positionsLine( positionMedia: number, @@ -75,7 +75,7 @@ If you need to draw a shape between two coordinates (for example, y coordinates * @param position1Media - media coordinate for the first point * @param position2Media - media coordinate for the second point * @param pixelRatio - pixel ratio for the corresponding axis (vertical or horizontal) - * @returns Position of of the start point and length dimension. + * @returns Position of the start point and length dimension. */ export function positionsBox( position1Media: number, @@ -91,7 +91,7 @@ export function positionsBox( } ``` -## Defaults Widths +## Default Widths Please refer to the following pages for functions defining the default widths of shapes drawn by the library: From 1447f646eaa1371b8d875b56775f94534dd7ede0 Mon Sep 17 00:00:00 2001 From: Mark Silverwood Date: Mon, 17 Jul 2023 11:44:41 +0100 Subject: [PATCH 11/11] applying typo fix from code review Co-authored-by: Romain Francois --- website/docs/plugins/pixel-perfect-rendering/widths/columns.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/docs/plugins/pixel-perfect-rendering/widths/columns.md b/website/docs/plugins/pixel-perfect-rendering/widths/columns.md index c2384f27de..10662adef3 100644 --- a/website/docs/plugins/pixel-perfect-rendering/widths/columns.md +++ b/website/docs/plugins/pixel-perfect-rendering/widths/columns.md @@ -216,7 +216,7 @@ export interface ColumnPositionItem { } /** - * Calculates the column positions and widths for bars using the existing the + * Calculates the column positions and widths for bars using the existing * array of items. * @param items - bar items which include an `x` property, and will be mutated to contain a column property * @param barSpacingMedia - bar spacing in media coordinates