diff --git a/.size-limit.js b/.size-limit.js index 7512fb3e78..b9e58ab0d0 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.07 KB', }, { name: 'ESM', path: 'dist/lightweight-charts.production.mjs', - limit: '46.86 KB', + limit: '47.01 KB', }, { name: 'Standalone-ESM', path: 'dist/lightweight-charts.standalone.production.mjs', - limit: '48.56 KB', + limit: '48.70 KB', }, { name: 'Standalone', path: 'dist/lightweight-charts.standalone.production.js', - limit: '48.61 KB', + limit: '48.74 KB', }, ]; diff --git a/src/api/chart-api.ts b/src/api/chart-api.ts index 07125ef4f5..a586a1ade1 100644 --- a/src/api/chart-api.ts +++ b/src/api/chart-api.ts @@ -120,6 +120,7 @@ export class ChartApi implements IChartApiBase, Da private readonly _seriesMapReversed: Map, SeriesApi> = new Map(); private readonly _clickedDelegate: Delegate> = new Delegate(); + private readonly _dblClickedDelegate: Delegate> = new Delegate(); private readonly _crosshairMovedDelegate: Delegate> = new Delegate(); private readonly _timeScaleApi: TimeScaleApi; @@ -143,6 +144,14 @@ export class ChartApi implements IChartApiBase, Da }, 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()) { @@ -158,6 +167,7 @@ export class ChartApi implements IChartApiBase, Da public remove(): void { this._chartWidget.clicked().unsubscribeAll(this); + this._chartWidget.dblClicked().unsubscribeAll(this); this._chartWidget.crosshairMoved().unsubscribeAll(this); this._timeScaleApi.destroy(); @@ -167,6 +177,7 @@ export class ChartApi implements IChartApiBase, Da this._seriesMapReversed.clear(); this._clickedDelegate.destroy(); + this._dblClickedDelegate.destroy(); this._crosshairMovedDelegate.destroy(); this._dataLayer.destroy(); } @@ -265,6 +276,14 @@ export class ChartApi implements IChartApiBase, Da this._crosshairMovedDelegate.unsubscribe(handler); } + public subscribeDblClick(handler: MouseEventHandler): void { + this._dblClickedDelegate.subscribe(handler); + } + + public unsubscribeDblClick(handler: MouseEventHandler): void { + this._dblClickedDelegate.unsubscribe(handler); + } + public priceScale(priceScaleId: string): IPriceScaleApi { return new PriceScaleApi(this._chartWidget, priceScaleId); } diff --git a/src/api/ichart-api.ts b/src/api/ichart-api.ts index 566bca5ad2..168c834109 100644 --- a/src/api/ichart-api.ts +++ b/src/api/ichart-api.ts @@ -223,6 +223,36 @@ export interface IChartApiBase { */ 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 593fd2c9d8..dd75b820fd 100644 --- a/src/gui/chart-widget.ts +++ b/src/gui/chart-widget.ts @@ -67,6 +67,7 @@ export class ChartWidget implements IDestroyable, IChartWidgetBas 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; @@ -163,6 +164,7 @@ export class ChartWidget implements IDestroyable, IChartWidgetBas for (const paneWidget of this._paneWidgets) { this._tableElement.removeChild(paneWidget.getElement()); paneWidget.clicked().unsubscribeAll(this); + paneWidget.dblClicked().unsubscribeAll(this); paneWidget.destroy(); } this._paneWidgets = []; @@ -180,6 +182,7 @@ export class ChartWidget implements IDestroyable, IChartWidgetBas this._crosshairMoved.destroy(); this._clicked.destroy(); + this._dblClicked.destroy(); this._uninstallObserver(); } @@ -246,6 +249,10 @@ export class ChartWidget implements IDestroyable, IChartWidgetBas return this._clicked; } + public dblClicked(): ISubscription { + return this._dblClicked; + } + public crosshairMoved(): ISubscription { return this._crosshairMoved; } @@ -696,6 +703,7 @@ export class ChartWidget implements IDestroyable, IChartWidgetBas 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(); @@ -708,6 +716,7 @@ export class ChartWidget implements IDestroyable, IChartWidgetBas 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); @@ -789,6 +798,14 @@ export class ChartWidget implements IDestroyable, IChartWidgetBas 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 4112d1e417..a72fa70186 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._fireMouseClickDelegate(this._dblClicked, 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(); @@ -501,10 +517,14 @@ export class PaneWidget implements IDestroyable, MouseEventHandlers { } private _fireClickedDelegate(event: MouseEventHandlerEventBase): void { + this._fireMouseClickDelegate(this._clicked, event); + } + + private _fireMouseClickDelegate(delegate: Delegate, 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); + if (delegate.hasListeners()) { + delegate.fire(this._model().timeScale().coordinateToIndex(x), { x, y }, event); } } diff --git a/src/model/price-range-impl.ts b/src/model/price-range-impl.ts index 539e7ed3b6..04e9b13e77 100644 --- a/src/model/price-range-impl.ts +++ b/src/model/price-range-impl.ts @@ -2,6 +2,22 @@ import { isNumber } from '../helpers/strict-type-checks'; import { PriceRange } from './series-options'; +function computeFiniteResult( + method: (...values: number[]) => number, + valueOne: number, + valueTwo: number, + fallback: number +): number { + const firstFinite = Number.isFinite(valueOne); + const secondFinite = Number.isFinite(valueTwo); + + if (firstFinite && secondFinite) { + return method(valueOne, valueTwo); + } + + return !firstFinite && !secondFinite ? fallback : (firstFinite ? valueOne : valueTwo); +} + export class PriceRangeImpl { private _minValue: number; private _maxValue!: number; @@ -43,8 +59,8 @@ export class PriceRangeImpl { return this; } return new PriceRangeImpl( - Math.min(this.minValue(), anotherRange.minValue()), - Math.max(this.maxValue(), anotherRange.maxValue()) + computeFiniteResult(Math.min, this.minValue(), anotherRange.minValue(), -Infinity), + computeFiniteResult(Math.max, this.maxValue(), anotherRange.maxValue(), Infinity) ); } 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(); +} 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(); +} 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..8d6834c0ac --- /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 + * 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 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 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, + }; +} +``` + +## Default 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..10662adef3 --- /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 + * 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, + }; +} +```