diff --git a/.circleci/config.yml b/.circleci/config.yml index 7e03439b18..a1bb4255e5 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -180,6 +180,13 @@ jobs: - store_test_results: path: test-results/ + type-tests: + executor: node16-executor + steps: + - checkout-with-deps + - run: npm run tsc-verify + - run: npm run type-tests + dts-changes: executor: node16-executor steps: @@ -298,31 +305,6 @@ jobs: paths: - website - deploy-docusaurus-website: - executor: node16-executor - environment: - USE_SSH: "true" - # We should be able to remove GIT_USER when we have upgraded to a Docusaurus version > 2.0.0-beta.9 - GIT_USER: "tvrobot" - CUSTOM_COMMIT_MESSAGE: "[skip ci] Deploy website based on $CIRCLE_SHA1" - steps: - - checkout-with-deps - - attach_workspace: - at: ./ - - run: - name: "Setting up git user" - command: | - git config --global user.name "TradingView" - git config --global user.email "noreply@tradingview.com" - - add_ssh_keys: - fingerprints: - - "cb:bc:16:a1:03:fb:b5:fb:69:4b:68:4d:33:a9:54:8c" - - run: - name: "Deploy website" - command: | - cd website - npm run deploy -- --skip-build - workflows: version: 2 @@ -359,6 +341,10 @@ workflows: filters: *default-filters requires: - build + - type-tests: + filters: *default-filters + requires: + - build - dts-changes: filters: *merge-based-filters requires: @@ -408,9 +394,3 @@ workflows: requires: - install-deps - install-deps-website - - deploy-docusaurus-website: - filters: - branches: - only: master - requires: - - build-docusaurus-website diff --git a/.eslintrc.js b/.eslintrc.js index 08151466d6..90afb847f8 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -11,7 +11,7 @@ function getNamingConventionRules(additionalDefaultFormats = []) { format: ['PascalCase'], filter: { match: true, - regex: '^(Area|Baseline|Bar|Candlestick|Histogram|Line)$', + regex: '^(Area|Baseline|Bar|Candlestick|Histogram|Line|Custom)$', }, }, ]; diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000000..0584405a63 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,48 @@ +name: Documentation Website Deployment + +on: + push: + branches: + - master + paths: + - '.github/workflows/deploy.yml' + - 'src/**' + - 'website/**' + +jobs: + deploy: + runs-on: ubuntu-22.04 + if: ${{ github.ref == 'refs/heads/master' }} + permissions: + contents: write + pages: write + concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + steps: + - uses: actions/checkout@v3 + + - name: Setup Node + uses: actions/setup-node@v3 + with: + node-version: '16' + + - name: Install library dependencies + run: npm install + - name: Build library + run: npm run build:prod + - name: Install website dependencies + working-directory: ./website + run: npm install + - name: Build documentation website + working-directory: ./website + run: npm run build + + - name: Deploy + uses: peaceiris/actions-gh-pages@v3 + if: ${{ github.ref == 'refs/heads/master' }} + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./website/build + user_name: 'TradingView' + user_email: 'noreply@tradingview.com' + commit_message: '[skip ci] Deploy website on ${{ github.event.head_commit.id }}' diff --git a/.size-limit.js b/.size-limit.js index ac5408feb1..7512fb3e78 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -4,21 +4,21 @@ module.exports = [ { name: 'CJS', path: 'dist/lightweight-charts.production.cjs', - limit: '45.00 KB', + limit: '46.94 KB', }, { name: 'ESM', path: 'dist/lightweight-charts.production.mjs', - limit: '44.95 KB', + limit: '46.86 KB', }, { name: 'Standalone-ESM', path: 'dist/lightweight-charts.standalone.production.mjs', - limit: '46.65 KB', + limit: '48.56 KB', }, { name: 'Standalone', path: 'dist/lightweight-charts.standalone.production.js', - limit: '46.70 KB', + limit: '48.61 KB', }, ]; diff --git a/package.json b/package.json index 80bc0f2dcf..6e043e4c95 100644 --- a/package.json +++ b/package.json @@ -54,23 +54,24 @@ "fancy-canvas": "2.1.0" }, "devDependencies": { - "@rollup/plugin-node-resolve": "~15.0.1", + "@rollup/plugin-node-resolve": "~15.0.2", "@rollup/plugin-replace": "~5.0.2", - "@size-limit/file": "~8.1.2", - "@types/chai": "~4.3.4", - "@types/glob": "~8.0.1", + "@rollup/plugin-terser": "~0.4.3", + "@size-limit/file": "~8.2.4", + "@types/chai": "~4.3.5", + "@types/glob": "~8.1.0", "@types/mocha": "~10.0.1", "@types/node": "~16.18.9", "@types/pixelmatch": "~5.2.4", "@types/pngjs": "~6.0.1", - "@typescript-eslint/eslint-plugin": "~5.50.0", - "@typescript-eslint/eslint-plugin-tslint": "~5.50.0", - "@typescript-eslint/parser": "~5.50.0", + "@typescript-eslint/eslint-plugin": "~5.59.6", + "@typescript-eslint/eslint-plugin-tslint": "~5.59.6", + "@typescript-eslint/parser": "~5.59.6", "bytes": "~3.1.2", "chai": "~4.3.7", "chai-exclude": "~2.1.0", "cross-env": "~7.0.3", - "dts-bundle-generator": "~7.2.0", + "dts-bundle-generator": "~8.0.1", "eslint": "~7.32.0", "eslint-plugin-deprecation": "~1.3.3", "eslint-plugin-import": "~2.27.5", @@ -82,30 +83,29 @@ "eslint-plugin-tsdoc": "~0.2.17", "eslint-plugin-unicorn": "~40.1.0", "express": "~4.18.2", - "glob": "~8.1.0", + "glob": "~10.2.5", "markdown-it": "~13.0.1", - "markdown-it-anchor": "~8.6.6", - "markdownlint-cli": "~0.33.0", - "memlab": "~1.1.37", + "markdown-it-anchor": "~8.6.7", + "markdownlint-cli": "~0.34.0", + "memlab": "~1.1.39", "mocha": "~10.2.0", "npm-run-all": "~4.1.5", "pixelmatch": "~5.3.0", - "pngjs": "~6.0.0", - "puppeteer": "~19.6.3", - "rimraf": "~4.1.2", - "rollup": "~3.14.0", - "rollup-plugin-terser": "~7.0.2", - "size-limit": "~8.1.2", + "pngjs": "~7.0.0", + "puppeteer": "~20.2.1", + "rimraf": "~5.0.1", + "rollup": "~3.22.0", + "size-limit": "~8.2.4", "ts-node": "~10.9.1", - "ts-transformer-properties-rename": "~0.14.0", + "ts-transformer-properties-rename": "~0.16.0", "ts-transformer-strip-const-enums": "~1.0.1", - "tslib": "2.5.0", + "tslib": "2.5.2", "tslint": "6.1.3", "tslint-eslint-rules": "~5.4.0", "tslint-microsoft-contrib": "~6.2.0", "ttypescript": "~1.5.15", "typescript": "4.9.5", - "yargs": "~17.6.2" + "yargs": "~17.7.2" }, "scripts": { "postinstall": "npm run install-hooks", @@ -129,7 +129,8 @@ "prepare-release": "npm-run-all clean build:release && npm run prepare-package-json-for-release", "prepare-package-json-for-release": "node ./scripts/clean-package-json.js", "size-limit": "size-limit", - "verify": "npm-run-all clean -p build:prod check-markdown-links -p lint check-dts-docs tsc-verify test size-limit", - "test": "mocha tests/unittests/**/*.spec.ts" + "verify": "npm-run-all clean -p build:prod check-markdown-links -p lint check-dts-docs tsc-verify test size-limit -p type-tests", + "test": "mocha tests/unittests/**/*.spec.ts", + "type-tests": "tsc -p ./tests/type-checks/tsconfig.composite.json --noEmit" } } diff --git a/rollup.config.js b/rollup.config.js index 0a550cd103..063fbf7c87 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,6 +1,6 @@ -const terser = require('rollup-plugin-terser').terser; const nodeResolve = require('@rollup/plugin-node-resolve').default; const replace = require('@rollup/plugin-replace'); +const terser = require('@rollup/plugin-terser').default; const packageJson = require('./package.json'); function getDevBuildMetadata() { diff --git a/src/api/chart-api.ts b/src/api/chart-api.ts index 88af1b3b93..07125ef4f5 100644 --- a/src/api/chart-api.ts +++ b/src/api/chart-api.ts @@ -1,13 +1,14 @@ import { ChartWidget, MouseEventParamsImpl, MouseEventParamsImplSupplier } from '../gui/chart-widget'; -import { assert, ensureDefined } from '../helpers/assertions'; +import { assert, ensure, ensureDefined } from '../helpers/assertions'; import { Delegate } from '../helpers/delegate'; import { warn } from '../helpers/logger'; import { clone, DeepPartial, isBoolean, merge } from '../helpers/strict-type-checks'; import { ChartOptionsImpl, ChartOptionsInternal } from '../model/chart-model'; -import { DataUpdatesConsumer, isFulfilledData, SeriesDataItemTypeMap } from '../model/data-consumer'; +import { DataUpdatesConsumer, isFulfilledData, SeriesDataItemTypeMap, WhitespaceData } from '../model/data-consumer'; import { DataLayer, DataUpdateResponse, SeriesChanges } from '../model/data-layer'; +import { CustomData, ICustomSeriesPaneView } from '../model/icustom-series'; import { IHorzScaleBehavior } from '../model/ihorz-scale-behavior'; import { Series } from '../model/series'; import { SeriesPlotRow } from '../model/series-data'; @@ -16,6 +17,8 @@ import { BarSeriesPartialOptions, BaselineSeriesPartialOptions, CandlestickSeriesPartialOptions, + CustomSeriesOptions, + CustomSeriesPartialOptions, fillUpDownCandlesticksColors, HistogramSeriesPartialOptions, LineSeriesPartialOptions, @@ -23,6 +26,7 @@ import { PriceFormat, PriceFormatBuiltIn, SeriesOptionsMap, + SeriesPartialOptions, SeriesPartialOptionsMap, SeriesStyleOptionsMap, SeriesType, @@ -40,6 +44,7 @@ import { barStyleDefaults, baselineStyleDefaults, candlestickStyleDefaults, + customStyleDefaults, histogramStyleDefaults, lineStyleDefaults, seriesOptionsDefaults, @@ -176,6 +181,27 @@ export class ChartApi implements IChartApiBase, Da this._chartWidget.resize(width, height, forceRepaint); } + public addCustomSeries< + TData extends CustomData, + TOptions extends CustomSeriesOptions, + TPartialOptions extends CustomSeriesPartialOptions = SeriesPartialOptions + >( + customPaneView: ICustomSeriesPaneView, + options?: SeriesPartialOptions + ): ISeriesApi<'Custom', HorzScaleItem, TData, TOptions, TPartialOptions> { + const paneView = ensure(customPaneView); + const defaults = { + ...customStyleDefaults, + ...paneView.defaultOptions(), + }; + return this._addSeriesImpl<'Custom', TData, TOptions, TPartialOptions>( + 'Custom', + defaults, + options, + paneView + ); + } + public addAreaSeries(options?: AreaSeriesPartialOptions): ISeriesApi<'Area', HorzScaleItem> { return this._addSeriesImpl('Area', areaStyleDefaults, options); } @@ -263,17 +289,27 @@ export class ChartApi implements IChartApiBase, Da return this._chartWidget.autoSizeActive(); } - private _addSeriesImpl( + public chartElement(): HTMLDivElement { + return this._chartWidget.element(); + } + + private _addSeriesImpl< + TSeries extends SeriesType, + TData extends WhitespaceData = SeriesDataItemTypeMap[TSeries], + TOptions extends SeriesOptionsMap[TSeries] = SeriesOptionsMap[TSeries], + TPartialOptions extends SeriesPartialOptionsMap[TSeries] = SeriesPartialOptionsMap[TSeries] + >( type: TSeries, styleDefaults: SeriesStyleOptionsMap[TSeries], - options: SeriesPartialOptionsMap[TSeries] = {} - ): ISeriesApi { + options: SeriesPartialOptionsMap[TSeries] = {}, + customPaneView?: ICustomSeriesPaneView + ): ISeriesApi { patchPriceFormat(options.priceFormat); const strictOptions = merge(clone(seriesOptionsDefaults), clone(styleDefaults), options) as SeriesOptionsMap[TSeries]; - const series = this._chartWidget.model().createSeries(type, strictOptions); + const series = this._chartWidget.model().createSeries(type, strictOptions, customPaneView); - const res = new SeriesApi(series, this, this, this._horzScaleBehavior); + const res = new SeriesApi(series, this, this, this, this._horzScaleBehavior); this._seriesMap.set(res, series); this._seriesMapReversed.set(series, res); @@ -296,8 +332,14 @@ export class ChartApi implements IChartApiBase, Da private _convertMouseParams(param: MouseEventParamsImpl): MouseEventParams { const seriesData: MouseEventParams['seriesData'] = new Map(); param.seriesData.forEach((plotRow: SeriesPlotRow, series: Series) => { - const data = getSeriesDataCreator(series.seriesType())(plotRow); - assert(isFulfilledData(data)); + const seriesType = series.seriesType(); + const data = getSeriesDataCreator(seriesType)(plotRow); + if (seriesType !== 'Custom') { + assert(isFulfilledData(data)); + } else { + const customWhitespaceChecker = series.customSeriesWhitespaceCheck(); + assert(!customWhitespaceChecker || customWhitespaceChecker(data) === false); + } seriesData.set(this._mapSeriesToApi(series), data); }); diff --git a/src/api/get-series-data-creator.ts b/src/api/get-series-data-creator.ts index fd11a3c108..86f89e49ed 100644 --- a/src/api/get-series-data-creator.ts +++ b/src/api/get-series-data-creator.ts @@ -8,26 +8,32 @@ import { SeriesDataItemTypeMap, SingleValueData, } from '../model/data-consumer'; +import { CustomData } from '../model/icustom-series'; import { PlotRow, PlotRowValueIndex } from '../model/plot-data'; import { AreaPlotRow, BarPlotRow, BaselinePlotRow, CandlestickPlotRow, + CustomPlotRow, LinePlotRow, SeriesPlotRow, } from '../model/series-data'; import { SeriesType } from '../model/series-options'; type SeriesPlotRowToDataMap = { - [T in keyof SeriesDataItemTypeMap]: (plotRow: SeriesPlotRow) => SeriesDataItemTypeMap[T]; + [T in keyof SeriesDataItemTypeMap]: (plotRow: SeriesPlotRow) => SeriesDataItemTypeMap[T]; }; function singleValueData(plotRow: PlotRow): SingleValueData { - return { + const data: SingleValueData = { value: plotRow.value[PlotRowValueIndex.Close], time: plotRow.originalTime as HorzScaleItem, }; + if (plotRow.customValues !== undefined) { + data.customValues = plotRow.customValues; + } + return data; } function lineData(plotRow: LinePlotRow): LineData { @@ -89,13 +95,17 @@ function baselineData(plotRow: BaselinePlotRow): BaselineData(plotRow: PlotRow): OhlcData { - return { + const data: OhlcData = { open: plotRow.value[PlotRowValueIndex.Open], high: plotRow.value[PlotRowValueIndex.High], low: plotRow.value[PlotRowValueIndex.Low], close: plotRow.value[PlotRowValueIndex.Close], time: plotRow.originalTime as HorzScaleItem, }; + if (plotRow.customValues !== undefined) { + data.customValues = plotRow.customValues; + } + return data; } function barData(plotRow: BarPlotRow): BarData { @@ -129,13 +139,21 @@ function candlestickData(plotRow: CandlestickPlotRow): Candlestic export function getSeriesDataCreator(seriesType: TSeriesType): (plotRow: SeriesPlotRow) => SeriesDataItemTypeMap[TSeriesType] { const seriesPlotRowToDataMap: SeriesPlotRowToDataMap = { - Area: areaData, - Line: lineData, - Baseline: baselineData, - Histogram: lineData, - Bar: barData, - Candlestick: candlestickData, + Area: areaData, + Line: lineData, + Baseline: baselineData, + Histogram: lineData, + Bar: barData, + Candlestick: candlestickData, + Custom: customData, }; - return seriesPlotRowToDataMap[seriesType]; } + +function customData(plotRow: CustomPlotRow): CustomData { + const time = plotRow.originalTime as HorzScaleItem; + return { + ...plotRow.data, + time, + }; +} diff --git a/src/api/ichart-api.ts b/src/api/ichart-api.ts index 7682959924..566bca5ad2 100644 --- a/src/api/ichart-api.ts +++ b/src/api/ichart-api.ts @@ -1,16 +1,19 @@ import { DeepPartial } from '../helpers/strict-type-checks'; import { ChartOptionsImpl } from '../model/chart-model'; -import { BarData, HistogramData, LineData } from '../model/data-consumer'; +import { BarData, HistogramData, LineData, WhitespaceData } from '../model/data-consumer'; import { Time } from '../model/horz-scale-behavior-time/types'; +import { CustomData, ICustomSeriesPaneView } from '../model/icustom-series'; import { Point } from '../model/point'; import { AreaSeriesPartialOptions, BarSeriesPartialOptions, BaselineSeriesPartialOptions, CandlestickSeriesPartialOptions, + CustomSeriesOptions, HistogramSeriesPartialOptions, LineSeriesPartialOptions, + SeriesPartialOptions, SeriesType, } from '../model/series-options'; import { Logical } from '../model/time-data'; @@ -46,7 +49,7 @@ export interface MouseEventParams { * Keys of the map are {@link ISeriesApi} instances. Values are prices. * Values of the map are original data items */ - seriesData: Map, BarData | LineData | HistogramData>; + seriesData: Map, BarData | LineData | HistogramData | CustomData>; /** * The {@link ISeriesApi} for the series at the point of the mouse event. */ @@ -87,6 +90,27 @@ export interface IChartApiBase { */ resize(width: number, height: number, forceRepaint?: boolean): void; + /** + * Creates a custom series with specified parameters. + * + * A custom series is a generic series which can be extended with a custom renderer to + * implement chart types which the library doesn't support by default. + * + * @param customPaneView - A custom series pane view which implements the custom renderer. + * @param customOptions - Customization parameters of the series being created. + * ```js + * const series = chart.addCustomSeries(myCustomPaneView); + * ``` + */ + addCustomSeries< + TData extends CustomData, + TOptions extends CustomSeriesOptions, + TPartialOptions extends SeriesPartialOptions = SeriesPartialOptions + >( + customPaneView: ICustomSeriesPaneView, + customOptions?: SeriesPartialOptions + ): ISeriesApi<'Custom', HorzScaleItem, TData | WhitespaceData, TOptions, TPartialOptions>; + /** * Creates an area series with specified parameters. * @@ -272,4 +296,12 @@ export interface IChartApiBase { * @returns Whether the `autoSize` option is enabled and the active. */ autoSizeActive(): boolean; + + /** + * Returns the generated div element containing the chart. This can be used for adding your own additional event listeners, or for measuring the + * elements dimensions and position within the document. + * + * @returns generated div element containing the chart. + */ + chartElement(): HTMLDivElement; } diff --git a/src/api/iseries-api.ts b/src/api/iseries-api.ts index b465f29fbd..a38ac606a9 100644 --- a/src/api/iseries-api.ts +++ b/src/api/iseries-api.ts @@ -16,6 +16,17 @@ import { Range } from '../model/time-data'; import { IPriceLine } from './iprice-line'; import { IPriceScaleApi } from './iprice-scale-api'; +import { ISeriesPrimitive } from './iseries-primitive-api'; + +/** + * The extent of the data change. + */ +export type DataChangedScope = 'full' | 'update'; + +/** + * A custom function use to handle data changed events. + */ +export type DataChangedHandler = (scope: DataChangedScope) => void; /** * Represents a range of bars and the number of bars outside the range. @@ -41,7 +52,13 @@ export interface BarsInfo extends Partial> { /** * Represents the interface for interacting with series. */ -export interface ISeriesApi { +export interface ISeriesApi< + TSeriesType extends SeriesType, + HorzScaleItem = Time, + TData = SeriesDataItemTypeMap[TSeriesType], + TOptions = SeriesOptionsMap[TSeriesType], + TPartialOptions = SeriesPartialOptionsMap[TSeriesType], + > { /** * Returns current price formatter * @@ -98,14 +115,14 @@ export interface ISeriesApi; + options(): Readonly; /** * Returns interface of the price scale the series is currently attached @@ -133,7 +150,7 @@ export interface ISeriesApi[TSeriesType][]): void; + setData(data: TData[]): void; /** * Adds new data item to the existing set (or updates the latest item if times of the passed/latest items are equal). @@ -158,7 +175,7 @@ export interface ISeriesApi[TSeriesType]): void; + update(bar: TData): void; /** * Returns a bar data by provided logical index. @@ -171,7 +188,46 @@ export interface ISeriesApi[TSeriesType] | null; + dataByIndex(logicalIndex: number, mismatchDirection?: MismatchDirection): TData | null; + + /** + * Returns all the bar data for the series. + * + * @returns Original data items provided via setData or update methods. + * @example + * ```js + * const originalData = series.data(); + * ``` + */ + data(): readonly TData[]; + + /** + * Subscribe to the data changed event. This event is fired whenever the `update` or `setData` method is evoked + * on the series. + * + * @param handler - Handler to be called on a data changed event. + * @example + * ```js + * function myHandler() { + * const data = series.data(); + * console.log(`The data has changed. New Data length: ${data.length}`); + * } + * + * series.subscribeDataChanged(myHandler); + * ``` + */ + subscribeDataChanged(handler: DataChangedHandler): void; + + /** + * Unsubscribe a handler that was previously subscribed using {@link subscribeDataChanged}. + * + * @param handler - Previously subscribed handler + * @example + * ```js + * chart.unsubscribeDataChanged(myHandler); + * ``` + */ + unsubscribeDataChanged(handler: DataChangedHandler): void; /** * Allows to set/replace all existing series markers with new ones. @@ -264,4 +320,19 @@ export interface ISeriesApi): void; + + /** + * Detaches additional drawing primitive from the series + * + * @param primitive - implementation of ISeriesPrimitive interface attached before + * Does nothing if specified primitive was not attached + */ + detachPrimitive(primitive: ISeriesPrimitive): void; } diff --git a/src/api/iseries-primitive-api.ts b/src/api/iseries-primitive-api.ts new file mode 100644 index 0000000000..9f1d8a04d6 --- /dev/null +++ b/src/api/iseries-primitive-api.ts @@ -0,0 +1,34 @@ +import { ISeriesPrimitiveBase } from '../model/iseries-primitive'; +import { SeriesOptionsMap, SeriesType } from '../model/series-options'; + +import { IChartApiBase } from './ichart-api'; +import { ISeriesApi } from './iseries-api'; + +/** + * Object containing references to the chart and series instances, and a requestUpdate method for triggering + * a refresh of the chart. + */ +export interface SeriesAttachedParameter< + HorzScaleItem, + TSeriesType extends SeriesType = keyof SeriesOptionsMap +> { + /** + * Chart instance. + */ + chart: IChartApiBase; + /** + * Series to which the Primitive is attached. + */ + series: ISeriesApi; + /** + * Request an update (redraw the chart) + */ + requestUpdate: () => void; +} + +/** + * Interface for series primitives. It must be implemented to add some external graphics to series. + */ +export type ISeriesPrimitive = ISeriesPrimitiveBase< + SeriesAttachedParameter +>; diff --git a/src/api/options/series-options-defaults.ts b/src/api/options/series-options-defaults.ts index 5cee840180..0b57034123 100644 --- a/src/api/options/series-options-defaults.ts +++ b/src/api/options/series-options-defaults.ts @@ -3,6 +3,7 @@ import { BarStyleOptions, BaselineStyleOptions, CandlestickStyleOptions, + CustomStyleOptions, HistogramStyleOptions, LastPriceAnimationMode, LineStyleOptions, @@ -36,12 +37,14 @@ export const lineStyleDefaults: LineStyleOptions = { lineStyle: LineStyle.Solid, lineWidth: 3, lineType: LineType.Simple, + lineVisible: true, crosshairMarkerVisible: true, crosshairMarkerRadius: 4, crosshairMarkerBorderColor: '', crosshairMarkerBorderWidth: 2, crosshairMarkerBackgroundColor: '', lastPriceAnimation: LastPriceAnimationMode.Disabled, + pointMarkersVisible: false, }; export const areaStyleDefaults: AreaStyleOptions = { @@ -52,12 +55,14 @@ export const areaStyleDefaults: AreaStyleOptions = { lineStyle: LineStyle.Solid, lineWidth: 3, lineType: LineType.Simple, + lineVisible: true, crosshairMarkerVisible: true, crosshairMarkerRadius: 4, crosshairMarkerBorderColor: '', crosshairMarkerBorderWidth: 2, crosshairMarkerBackgroundColor: '', lastPriceAnimation: LastPriceAnimationMode.Disabled, + pointMarkersVisible: false, }; export const baselineStyleDefaults: BaselineStyleOptions = { @@ -77,6 +82,7 @@ export const baselineStyleDefaults: BaselineStyleOptions = { lineWidth: 3, lineStyle: LineStyle.Solid, lineType: LineType.Simple, + lineVisible: true, crosshairMarkerVisible: true, crosshairMarkerRadius: 4, @@ -85,6 +91,7 @@ export const baselineStyleDefaults: BaselineStyleOptions = { crosshairMarkerBackgroundColor: '', lastPriceAnimation: LastPriceAnimationMode.Disabled, + pointMarkersVisible: false, }; export const histogramStyleDefaults: HistogramStyleOptions = { @@ -92,6 +99,10 @@ export const histogramStyleDefaults: HistogramStyleOptions = { base: 0, }; +export const customStyleDefaults: CustomStyleOptions = { + color: '#2196f3', +}; + export const seriesOptionsDefaults: SeriesOptionsCommon = { title: '', visible: true, diff --git a/src/api/series-api.ts b/src/api/series-api.ts index 7e8a59c137..a2bcb64ca1 100644 --- a/src/api/series-api.ts +++ b/src/api/series-api.ts @@ -1,17 +1,21 @@ import { IPriceFormatter } from '../formatters/iprice-formatter'; import { ensureNotNull } from '../helpers/assertions'; +import { Delegate } from '../helpers/delegate'; +import { IDestroyable } from '../helpers/idestroyable'; import { clone, merge } from '../helpers/strict-type-checks'; import { BarPrice } from '../model/bar'; import { Coordinate } from '../model/coordinate'; -import { DataUpdatesConsumer, SeriesDataItemTypeMap } from '../model/data-consumer'; +import { DataUpdatesConsumer, SeriesDataItemTypeMap, WhitespaceData } from '../model/data-consumer'; import { checkItemsAreOrdered, checkPriceLineOptions, checkSeriesValuesType } from '../model/data-validators'; import { IHorzScaleBehavior, InternalHorzScaleItem } from '../model/ihorz-scale-behavior'; +import { ISeriesPrimitiveBase } from '../model/iseries-primitive'; import { MismatchDirection } from '../model/plot-list'; import { CreatePriceLineOptions, PriceLineOptions } from '../model/price-line-options'; import { RangeImpl } from '../model/range-impl'; import { Series } from '../model/series'; +import { SeriesPlotRow } from '../model/series-data'; import { SeriesMarker } from '../model/series-markers'; import { SeriesOptionsMap, @@ -23,25 +27,47 @@ import { TimeScaleVisibleRange } from '../model/time-scale-visible-range'; import { IPriceScaleApiProvider } from './chart-api'; import { getSeriesDataCreator } from './get-series-data-creator'; +import { type IChartApiBase } from './ichart-api'; import { IPriceLine } from './iprice-line'; import { IPriceScaleApi } from './iprice-scale-api'; -import { BarsInfo, ISeriesApi } from './iseries-api'; +import { BarsInfo, DataChangedHandler, DataChangedScope, ISeriesApi } from './iseries-api'; +import { ISeriesPrimitive } from './iseries-primitive-api'; import { priceLineOptionsDefaults } from './options/price-line-options-defaults'; import { PriceLine } from './price-line-api'; -export class SeriesApi implements ISeriesApi { +export class SeriesApi< + TSeriesType extends SeriesType, + HorzScaleItem, + TData extends WhitespaceData = SeriesDataItemTypeMap[TSeriesType], + TOptions extends SeriesOptionsMap[TSeriesType] = SeriesOptionsMap[TSeriesType], + TPartialOptions extends SeriesPartialOptionsMap[TSeriesType] = SeriesPartialOptionsMap[TSeriesType] +> implements + ISeriesApi, + IDestroyable { protected _series: Series; protected _dataUpdatesConsumer: DataUpdatesConsumer; + protected readonly _chartApi: IChartApiBase; private readonly _priceScaleApiProvider: IPriceScaleApiProvider; - private readonly _horzScaleBehavior: IHorzScaleBehavior; - - public constructor(series: Series, dataUpdatesConsumer: DataUpdatesConsumer, priceScaleApiProvider: IPriceScaleApiProvider, horzScaleBehavior: IHorzScaleBehavior) { + private readonly _dataChangedDelegate: Delegate = new Delegate(); + + public constructor( + series: Series, + dataUpdatesConsumer: DataUpdatesConsumer, + priceScaleApiProvider: IPriceScaleApiProvider, + chartApi: IChartApiBase, + horzScaleBehavior: IHorzScaleBehavior + ) { this._series = series; this._dataUpdatesConsumer = dataUpdatesConsumer; this._priceScaleApiProvider = priceScaleApiProvider; this._horzScaleBehavior = horzScaleBehavior; + this._chartApi = chartApi; + } + + public destroy(): void { + this._dataChangedDelegate.destroy(); } public priceFormatter(): IPriceFormatter { @@ -118,27 +144,44 @@ export class SeriesApi implements return result; } - public setData(data: SeriesDataItemTypeMap[TSeriesType][]): void { + public setData(data: TData[]): void { checkItemsAreOrdered(data, this._horzScaleBehavior); checkSeriesValuesType(this._series.seriesType(), data); this._dataUpdatesConsumer.applyNewData(this._series, data); + this._onDataChanged('full'); } - public update(bar: SeriesDataItemTypeMap[TSeriesType]): void { + public update(bar: TData): void { checkSeriesValuesType(this._series.seriesType(), [bar]); this._dataUpdatesConsumer.updateData(this._series, bar); + this._onDataChanged('update'); } - public dataByIndex(logicalIndex: number, mismatchDirection?: MismatchDirection): SeriesDataItemTypeMap[TSeriesType] | null { + public dataByIndex(logicalIndex: number, mismatchDirection?: MismatchDirection): TData | null { const data = this._series.bars().search(logicalIndex as unknown as TimePointIndex, mismatchDirection); if (data === null) { // actually it can be a whitespace return null; } - return getSeriesDataCreator(this.seriesType())(data); + const creator = getSeriesDataCreator(this.seriesType()); + return creator(data) as TData | null; + } + + public data(): readonly TData[] { + const seriesCreator = getSeriesDataCreator(this.seriesType()); + const rows = this._series.bars().rows(); + return rows.map((row: SeriesPlotRow) => seriesCreator(row) as TData); + } + + public subscribeDataChanged(handler: DataChangedHandler): void { + this._dataChangedDelegate.subscribe(handler); + } + + public unsubscribeDataChanged(handler: DataChangedHandler): void { + this._dataChangedDelegate.unsubscribe(handler); } public setMarkers(data: SeriesMarker[]): void { @@ -162,12 +205,12 @@ export class SeriesApi implements }); } - public applyOptions(options: SeriesPartialOptionsMap[TSeriesType]): void { + public applyOptions(options: TPartialOptions): void { this._series.applyOptions(options); } - public options(): Readonly { - return clone(this._series.options()); + public options(): Readonly { + return clone(this._series.options() as TOptions); } public priceScale(): IPriceScaleApi { @@ -189,4 +232,30 @@ export class SeriesApi implements public seriesType(): TSeriesType { return this._series.seriesType(); } + + public attachPrimitive(primitive: ISeriesPrimitive): void { + // at this point we cast the generic to unknown because we + // don't want the model to know the types of the API (◑_◑) + this._series.attachPrimitive(primitive as ISeriesPrimitiveBase); + if (primitive.attached) { + primitive.attached({ + chart: this._chartApi, + series: this, + requestUpdate: () => this._series.model().fullUpdate(), + }); + } + } + + public detachPrimitive(primitive: ISeriesPrimitive): void { + this._series.detachPrimitive(primitive as ISeriesPrimitiveBase); + if (primitive.detached) { + primitive.detached(); + } + } + + private _onDataChanged(scope: DataChangedScope): void { + if (this._dataChangedDelegate.hasListeners()) { + this._dataChangedDelegate.fire(scope); + } + } } diff --git a/src/api/time-scale-api.ts b/src/api/time-scale-api.ts index be913bea94..7ca99182d1 100644 --- a/src/api/time-scale-api.ts +++ b/src/api/time-scale-api.ts @@ -194,7 +194,10 @@ export class TimeScaleApi implements ITimeScaleApi } public options(): Readonly { - return clone(this._timeScale.options()); + return { + ...clone(this._timeScale.options()), + barSpacing: this._timeScale.barSpacing(), + }; } private _onVisibleBarsChanged(): void { diff --git a/src/gui/chart-widget.ts b/src/gui/chart-widget.ts index 8a96d52576..593fd2c9d8 100644 --- a/src/gui/chart-widget.ts +++ b/src/gui/chart-widget.ts @@ -48,6 +48,7 @@ export interface IChartWidgetBase { model(): IChartModelBase; paneWidgets(): PaneWidget[]; options(): ChartOptionsInternalBase; + setCursorStyle(style: string | null): void; } export class ChartWidget implements IDestroyable, IChartWidgetBase { @@ -60,7 +61,7 @@ export class ChartWidget implements IDestroyable, IChartWidgetBas private _width: number = 0; private _leftPriceAxisWidth: number = 0; private _rightPriceAxisWidth: number = 0; - private _element: HTMLElement; + private _element: HTMLDivElement; private readonly _tableElement: HTMLElement; private _timeAxisWidget: TimeAxisWidget; private _invalidateMask: InvalidateMask | null = null; @@ -71,6 +72,7 @@ export class ChartWidget implements IDestroyable, IChartWidgetBas private _observer: ResizeObserver | null = null; private _container: HTMLElement; + private _cursorStyleOverride: string | null = null; private readonly _horzScaleBehavior: IHorzScaleBehavior; @@ -82,6 +84,7 @@ export class ChartWidget implements IDestroyable, IChartWidgetBas this._element = document.createElement('div'); this._element.classList.add('tv-lightweight-charts'); this._element.style.overflow = 'hidden'; + this._element.style.direction = 'ltr'; this._element.style.width = '100%'; this._element.style.height = '100%'; disableSelection(this._element); @@ -290,6 +293,23 @@ export class ChartWidget implements IDestroyable, IChartWidgetBas return this._options.autoSize && this._observer !== null; } + public element(): HTMLDivElement { + return this._element; + } + + public setCursorStyle(style: string | null): void { + this._cursorStyleOverride = style; + if (this._cursorStyleOverride) { + this.element().style.setProperty('cursor', style); + } else { + this.element().style.removeProperty('cursor'); + } + } + + public getCursorOverrideStyle(): string | null { + return this._cursorStyleOverride; + } + // eslint-disable-next-line complexity private _applyAutoSizeOptions(options: DeepPartial>): void { if (options.autoSize === undefined && this._observer && (options.width !== undefined || options.height !== undefined)) { @@ -812,6 +832,7 @@ export class ChartWidget implements IDestroyable, IChartWidgetBas if (this._observer !== null) { this._observer.disconnect(); } + this._observer = null; } } diff --git a/src/gui/draw-functions.ts b/src/gui/draw-functions.ts new file mode 100644 index 0000000000..b03d54455c --- /dev/null +++ b/src/gui/draw-functions.ts @@ -0,0 +1,52 @@ +import { CanvasRenderingTarget2D } from 'fancy-canvas'; + +import { IDataSource } from '../model/idata-source'; +import { Pane } from '../model/pane'; +import { IPaneRenderer } from '../renderers/ipane-renderer'; + +import { IPaneViewsGetter } from './ipane-view-getter'; + +export type DrawFunction = ( + renderer: IPaneRenderer, + target: CanvasRenderingTarget2D, + isHovered: boolean, + hitTestData?: unknown +) => void; + +export function drawBackground( + renderer: IPaneRenderer, + target: CanvasRenderingTarget2D, + isHovered: boolean, + hitTestData?: unknown +): void { + if (renderer.drawBackground) { + renderer.drawBackground(target, isHovered, hitTestData); + } +} + +export function drawForeground( + renderer: IPaneRenderer, + target: CanvasRenderingTarget2D, + isHovered: boolean, + hitTestData?: unknown +): void { + renderer.draw(target, isHovered, hitTestData); +} + +type DrawRendererFn = (renderer: IPaneRenderer) => void; + +export function drawSourcePaneViews( + paneViewsGetter: IPaneViewsGetter, + drawRendererFn: DrawRendererFn, + source: IDataSource, + pane: Pane +): void { + const paneViews = paneViewsGetter(source, pane); + + for (const paneView of paneViews) { + const renderer = paneView.renderer(); + if (renderer !== null) { + drawRendererFn(renderer); + } + } +} diff --git a/src/gui/iaxis-view-getters.ts b/src/gui/iaxis-view-getters.ts new file mode 100644 index 0000000000..518cae44ce --- /dev/null +++ b/src/gui/iaxis-view-getters.ts @@ -0,0 +1,9 @@ +import { IDataSource } from '../model/idata-source'; +import { IAxisView } from '../views/pane/iaxis-view'; + +type IAxisViewsGetter = ( + source: IDataSource +) => readonly IAxisView[]; + +export type IPriceAxisViewsGetter = IAxisViewsGetter; +export type ITimeAxisViewsGetter = IAxisViewsGetter; diff --git a/src/gui/ipane-view-getter.ts b/src/gui/ipane-view-getter.ts new file mode 100644 index 0000000000..5c365c56c1 --- /dev/null +++ b/src/gui/ipane-view-getter.ts @@ -0,0 +1,10 @@ +import { + IDataSource, +} from '../model/idata-source'; +import { Pane } from '../model/pane'; +import { IPaneView } from '../views/pane/ipane-view'; + +export type IPaneViewsGetter = ( + source: IDataSource, + pane: Pane +) => readonly IPaneView[]; diff --git a/src/gui/pane-hit-test.ts b/src/gui/pane-hit-test.ts new file mode 100644 index 0000000000..e8014139dd --- /dev/null +++ b/src/gui/pane-hit-test.ts @@ -0,0 +1,139 @@ +import { HoveredObject } from '../model/chart-model'; +import { Coordinate } from '../model/coordinate'; +import { IPriceDataSource } from '../model/iprice-data-source'; +import { + PrimitiveHoveredItem, + SeriesPrimitivePaneViewZOrder, +} from '../model/iseries-primitive'; +import { Pane } from '../model/pane'; +import { IPaneView } from '../views/pane/ipane-view'; + +export interface HitTestResult { + source: IPriceDataSource; + object?: HoveredObject; + view?: IPaneView; + cursorStyle?: string; +} + +export interface HitTestPaneViewResult { + view: IPaneView; + object?: HoveredObject; +} + +interface BestPrimitiveHit { + hit: PrimitiveHoveredItem; + source: IPriceDataSource; +} + +// returns true if item is above reference +function comparePrimitiveZOrder( + item: SeriesPrimitivePaneViewZOrder, + reference?: SeriesPrimitivePaneViewZOrder +): boolean { + return ( + !reference || + (item === 'top' && reference !== 'top') || + (item === 'normal' && reference === 'bottom') + ); +} + +function findBestPrimitiveHitTest( + sources: readonly IPriceDataSource[], + x: Coordinate, + y: Coordinate +): BestPrimitiveHit | null { + let bestPrimitiveHit: PrimitiveHoveredItem | undefined; + let bestHitSource: IPriceDataSource | undefined; + for (const source of sources) { + const primitiveHitResults = source.primitiveHitTest?.(x, y) ?? []; + for (const hitResult of primitiveHitResults) { + if (comparePrimitiveZOrder(hitResult.zOrder, bestPrimitiveHit?.zOrder)) { + bestPrimitiveHit = hitResult; + bestHitSource = source; + } + } + } + if (!bestPrimitiveHit || !bestHitSource) { + return null; + } + return { + hit: bestPrimitiveHit, + source: bestHitSource, + }; +} + +function convertPrimitiveHitResult( + primitiveHit: BestPrimitiveHit +): HitTestResult { + return { + source: primitiveHit.source, + object: { + externalId: primitiveHit.hit.externalId, + }, + cursorStyle: primitiveHit.hit.cursorStyle, + }; +} + +/** + * Performs a hit test on a collection of pane views to determine which view and object + * is located at a given coordinate (x, y) and returns the matching pane view and + * hit-tested result object, or null if no match is found. + */ +function hitTestPaneView( + paneViews: readonly IPaneView[], + x: Coordinate, + y: Coordinate +): HitTestPaneViewResult | null { + for (const paneView of paneViews) { + const renderer = paneView.renderer(); + if (renderer !== null && renderer.hitTest) { + const result = renderer.hitTest(x, y); + if (result !== null) { + return { + view: paneView, + object: result, + }; + } + } + } + + return null; +} + +export function hitTestPane( + pane: Pane, + x: Coordinate, + y: Coordinate +): HitTestResult | null { + const sources = pane.orderedSources(); + const bestPrimitiveHit = findBestPrimitiveHitTest(sources, x, y); + if (bestPrimitiveHit?.hit.zOrder === 'top') { + // a primitive hit on the 'top' layer will always beat the built-in hit tests + // (on normal layer) so we can return early here. + return convertPrimitiveHitResult(bestPrimitiveHit); + } + for (const source of sources) { + if (bestPrimitiveHit && bestPrimitiveHit.source === source && bestPrimitiveHit.hit.zOrder !== 'bottom' && !bestPrimitiveHit.hit.isBackground) { + // a primitive will be drawn above a built-in item like a series marker + // therefore it takes precedence here. + return convertPrimitiveHitResult(bestPrimitiveHit); + } + const sourceResult = hitTestPaneView(source.paneViews(pane), x, y); + if (sourceResult !== null) { + return { + source: source, + view: sourceResult.view, + object: sourceResult.object, + }; + } + if (bestPrimitiveHit && bestPrimitiveHit.source === source && bestPrimitiveHit.hit.zOrder !== 'bottom' && bestPrimitiveHit.hit.isBackground) { + return convertPrimitiveHitResult(bestPrimitiveHit); + } + } + if (bestPrimitiveHit?.hit) { + // return primitive hits for the 'bottom' layer + return convertPrimitiveHitResult(bestPrimitiveHit); + } + + return null; +} diff --git a/src/gui/pane-widget.ts b/src/gui/pane-widget.ts index 42cf31f1b1..4112d1e417 100644 --- a/src/gui/pane-widget.ts +++ b/src/gui/pane-widget.ts @@ -14,11 +14,10 @@ import { Delegate } from '../helpers/delegate'; import { IDestroyable } from '../helpers/idestroyable'; import { ISubscription } from '../helpers/isubscription'; -import { HoveredObject, IChartModelBase, TrackingModeExitMode } from '../model/chart-model'; +import { IChartModelBase, TrackingModeExitMode } from '../model/chart-model'; import { Coordinate } from '../model/coordinate'; import { IDataSource } from '../model/idata-source'; import { InvalidationLevel } from '../model/invalidate-mask'; -import { IPriceDataSource } from '../model/iprice-data-source'; import { KineticAnimation } from '../model/kinetic-animation'; import { Pane } from '../model/pane'; import { Point } from '../model/point'; @@ -29,7 +28,10 @@ import { IPaneView } from '../views/pane/ipane-view'; import { createBoundCanvas } from './canvas-utils'; import { IChartWidgetBase } from './chart-widget'; +import { drawBackground, drawForeground, DrawFunction, drawSourcePaneViews } from './draw-functions'; +import { IPaneViewsGetter } from './ipane-view-getter'; import { MouseEventHandler, MouseEventHandlerEventBase, MouseEventHandlerMouseEvent, MouseEventHandlers, MouseEventHandlerTouchEvent, Position, TouchMouseEvent } from './mouse-event-handler'; +import { hitTestPane, HitTestResult } from './pane-hit-test'; import { PriceAxisWidget, PriceAxisWidgetSide } from './price-axis-widget'; const enum KineticScrollConstants { @@ -39,41 +41,17 @@ const enum KineticScrollConstants { ScrollMinMove = 15, } -type DrawFunction = (renderer: IPaneRenderer, target: CanvasRenderingTarget2D, isHovered: boolean, hitTestData?: unknown) => void; - -function drawBackground(renderer: IPaneRenderer, target: CanvasRenderingTarget2D, isHovered: boolean, hitTestData?: unknown): void { - if (renderer.drawBackground) { - renderer.drawBackground(target, isHovered, hitTestData); - } -} - -function drawForeground(renderer: IPaneRenderer, target: CanvasRenderingTarget2D, isHovered: boolean, hitTestData?: unknown): void { - renderer.draw(target, isHovered, hitTestData); +function sourceBottomPaneViews(source: IDataSource, pane: Pane): readonly IPaneView[] { + return source.bottomPaneViews?.(pane) ?? []; } - -type PaneViewsGetter = (source: IDataSource, pane: Pane) => readonly IPaneView[]; - function sourcePaneViews(source: IDataSource, pane: Pane): readonly IPaneView[] { - return source.paneViews(pane); + return source.paneViews?.(pane) ?? []; } - function sourceLabelPaneViews(source: IDataSource, pane: Pane): readonly IPaneView[] { - return source.labelPaneViews(pane); + return source.labelPaneViews?.(pane) ?? []; } - function sourceTopPaneViews(source: IDataSource, pane: Pane): readonly IPaneView[] { - return source.topPaneViews !== undefined ? source.topPaneViews(pane) : []; -} - -export interface HitTestResult { - source: IPriceDataSource; - object?: HoveredObject; - view: IPaneView; -} - -interface HitTestPaneViewResult { - view: IPaneView; - object?: HoveredObject; + return source.topPaneViews?.(pane) ?? []; } interface StartScrollPosition extends Point { @@ -276,6 +254,7 @@ export class PaneWidget implements IDestroyable, MouseEventHandlers { const y = event.localY; this._setCrosshairPosition(x, y, event); const hitTest = this.hitTest(x, y); + this._chart.setCursorStyle(hitTest?.cursorStyle ?? null); this._model().setHoveredSource(hitTest && { source: hitTest.source, object: hitTest.object }); } @@ -397,19 +376,7 @@ export class PaneWidget implements IDestroyable, MouseEventHandlers { return null; } - const sources = state.orderedSources(); - for (const source of sources) { - const sourceResult = this._hitTestPaneView(source.paneViews(state), x, y); - if (sourceResult !== null) { - return { - source: source, - view: sourceResult.view, - object: sourceResult.object, - }; - } - } - - return null; + return hitTestPane(state, x, y); } public setPriceAxisSize(width: number, position: PriceAxisWidgetSide): void { @@ -493,6 +460,7 @@ export class PaneWidget implements IDestroyable, MouseEventHandlers { this._drawBackground(scope); }); if (this._state) { + this._drawSources(target, sourceBottomPaneViews); this._drawGrid(target); this._drawWatermark(target); this._drawSources(target, sourcePaneViews); @@ -507,8 +475,8 @@ export class PaneWidget implements IDestroyable, MouseEventHandlers { topTarget.useBitmapCoordinateSpace(({ context: ctx, bitmapSize }: BitmapCoordinatesRenderingScope) => { ctx.clearRect(0, 0, bitmapSize.width, bitmapSize.height); }); - this._drawSources(topTarget, sourceTopPaneViews); this._drawCrosshair(topTarget); + this._drawSources(topTarget, sourceTopPaneViews); } } @@ -520,6 +488,10 @@ export class PaneWidget implements IDestroyable, MouseEventHandlers { return this._rightPriceAxisWidget; } + public drawAdditionalSources(target: CanvasRenderingTarget2D, paneViewsGetter: IPaneViewsGetter): void { + this._drawSources(target, paneViewsGetter); + } + private _onStateDestroyed(): void { if (this._state !== null) { this._state.onDestroyed().unsubscribeAll(this); @@ -569,7 +541,7 @@ export class PaneWidget implements IDestroyable, MouseEventHandlers { this._drawSourceImpl(target, sourcePaneViews, drawForeground, this._model().crosshairSource()); } - private _drawSources(target: CanvasRenderingTarget2D, paneViewsGetter: PaneViewsGetter): void { + private _drawSources(target: CanvasRenderingTarget2D, paneViewsGetter: IPaneViewsGetter): void { const state = ensureNotNull(this._state); const sources = state.orderedSources(); @@ -584,41 +556,19 @@ export class PaneWidget implements IDestroyable, MouseEventHandlers { private _drawSourceImpl( target: CanvasRenderingTarget2D, - paneViewsGetter: PaneViewsGetter, + paneViewsGetter: IPaneViewsGetter, drawFn: DrawFunction, source: IDataSource ): void { const state = ensureNotNull(this._state); - const paneViews = paneViewsGetter(source, state); const hoveredSource = state.model().hoveredSource(); const isHovered = hoveredSource !== null && hoveredSource.source === source; const objecId = hoveredSource !== null && isHovered && hoveredSource.object !== undefined ? hoveredSource.object.hitTestData : undefined; - for (const paneView of paneViews) { - const renderer = paneView.renderer(); - if (renderer !== null) { - drawFn(renderer, target, isHovered, objecId); - } - } - } - - private _hitTestPaneView(paneViews: readonly IPaneView[], x: Coordinate, y: Coordinate): HitTestPaneViewResult | null { - for (const paneView of paneViews) { - const renderer = paneView.renderer(); - if (renderer !== null && renderer.hitTest) { - const result = renderer.hitTest(x, y); - if (result !== null) { - return { - view: paneView, - object: result, - }; - } - } - } - - return null; + const drawRendererFn = (renderer: IPaneRenderer) => drawFn(renderer, target, isHovered, objecId); + drawSourcePaneViews(paneViewsGetter, drawRendererFn, source, state); } private _recreatePriceAxisWidgets(): void { diff --git a/src/gui/price-axis-widget.ts b/src/gui/price-axis-widget.ts index bda8ed608f..a6585c786d 100644 --- a/src/gui/price-axis-widget.ts +++ b/src/gui/price-axis-widget.ts @@ -19,15 +19,18 @@ import { Coordinate } from '../model/coordinate'; import { IDataSource } from '../model/idata-source'; import { InvalidationLevel } from '../model/invalidate-mask'; import { IPriceDataSource } from '../model/iprice-data-source'; +import { SeriesPrimitivePaneViewZOrder } from '../model/iseries-primitive'; import { LayoutOptions } from '../model/layout-options'; import { PriceScalePosition } from '../model/pane'; import { PriceMark, PriceScale } from '../model/price-scale'; import { TextWidthCache } from '../model/text-width-cache'; import { PriceAxisViewRendererOptions } from '../renderers/iprice-axis-view-renderer'; import { PriceAxisRendererOptionsProvider } from '../renderers/price-axis-renderer-options-provider'; +import { IAxisView } from '../views/pane/iaxis-view'; import { IPriceAxisView } from '../views/price-axis/iprice-axis-view'; import { createBoundCanvas } from './canvas-utils'; +import { IPriceAxisViewsGetter } from './iaxis-view-getters'; import { suggestPriceScaleWidth } from './internal-layout-sizes-hints'; import { MouseEventHandler, MouseEventHandlers, TouchMouseEvent } from './mouse-event-handler'; import { PaneWidget } from './pane-widget'; @@ -49,6 +52,20 @@ const enum Constants { LabelOffset = 5, } +function buildPriceAxisViewsGetter( + zOrder: SeriesPrimitivePaneViewZOrder, + priceScaleId: PriceAxisWidgetSide +): IPriceAxisViewsGetter { + return (source: IDataSource): readonly IAxisView[] => { + const psId = source.priceScale()?.id() ?? ''; + if (psId !== priceScaleId) { + // exclude if source is using a different price scale. + return []; + } + return source.pricePaneViews?.(zOrder) ?? []; + }; +} + export class PriceAxisWidget implements IDestroyable { private readonly _pane: PaneWidget; private readonly _options: Readonly; @@ -73,6 +90,10 @@ export class PriceAxisWidget implements IDestroyable { private _prevOptimalWidth: number = 0; private _isSettingSize: boolean = false; + private _sourcePaneViews: IPriceAxisViewsGetter; + private _sourceTopPaneViews: IPriceAxisViewsGetter; + private _sourceBottomPaneViews: IPriceAxisViewsGetter; + public constructor(pane: PaneWidget, options: Readonly, rendererOptionsProvider: PriceAxisRendererOptionsProvider, side: PriceAxisWidgetSide) { this._pane = pane; this._options = options; @@ -80,6 +101,10 @@ export class PriceAxisWidget implements IDestroyable { this._rendererOptionsProvider = rendererOptionsProvider; this._isLeft = side === 'left'; + this._sourcePaneViews = buildPriceAxisViewsGetter('normal', side); + this._sourceTopPaneViews = buildPriceAxisViewsGetter('top', side); + this._sourceBottomPaneViews = buildPriceAxisViewsGetter('bottom', side); + this._cell = document.createElement('div'); this._cell.style.height = '100%'; this._cell.style.overflow = 'hidden'; @@ -275,7 +300,9 @@ export class PriceAxisWidget implements IDestroyable { this._drawBackground(scope); this._drawBorder(scope); }); + this._pane.drawAdditionalSources(target, this._sourceBottomPaneViews); this._drawTickMarks(target); + this._pane.drawAdditionalSources(target, this._sourcePaneViews); this._drawBackLabels(target); } } @@ -287,6 +314,7 @@ export class PriceAxisWidget implements IDestroyable { ctx.clearRect(0, 0, bitmapSize.width, bitmapSize.height); }); this._drawCrosshairLabel(topTarget); + this._pane.drawAdditionalSources(topTarget, this._sourceTopPaneViews); } } diff --git a/src/gui/time-axis-widget.ts b/src/gui/time-axis-widget.ts index 8c909974f8..a34fbfb030 100644 --- a/src/gui/time-axis-widget.ts +++ b/src/gui/time-axis-widget.ts @@ -18,12 +18,18 @@ import { makeFont } from '../helpers/make-font'; import { IDataSource } from '../model/idata-source'; import { IHorzScaleBehavior } from '../model/ihorz-scale-behavior'; import { InvalidationLevel } from '../model/invalidate-mask'; +import { SeriesPrimitivePaneViewZOrder } from '../model/iseries-primitive'; import { LayoutOptions } from '../model/layout-options'; +import { Pane } from '../model/pane'; import { TextWidthCache } from '../model/text-width-cache'; +import { IPaneRenderer } from '../renderers/ipane-renderer'; import { TimeAxisViewRendererOptions } from '../renderers/itime-axis-view-renderer'; +import { IAxisView } from '../views/pane/iaxis-view'; import { createBoundCanvas } from './canvas-utils'; import { ChartWidget } from './chart-widget'; +import { drawBackground, drawForeground, drawSourcePaneViews } from './draw-functions'; +import { ITimeAxisViewsGetter } from './iaxis-view-getters'; import { MouseEventHandler, MouseEventHandlers, MouseEventHandlerTouchEvent, TouchMouseEvent } from './mouse-event-handler'; import { PriceAxisStub, PriceAxisStubParams } from './price-axis-stub'; @@ -37,6 +43,13 @@ const enum CursorType { EwResize, } +function buildTimeAxisViewsGetter(zOrder: SeriesPrimitivePaneViewZOrder): ITimeAxisViewsGetter { + return (source: IDataSource): readonly IAxisView[] => source.timePaneViews?.(zOrder) ?? []; +} +const sourcePaneViews = buildTimeAxisViewsGetter('normal'); +const sourceTopPaneViews = buildTimeAxisViewsGetter('top'); +const sourceBottomPaneViews = buildTimeAxisViewsGetter('bottom'); + export class TimeAxisWidget implements MouseEventHandlers, IDestroyable { private readonly _chart: ChartWidget; private readonly _options: LayoutOptions; @@ -291,8 +304,10 @@ export class TimeAxisWidget implements MouseEventHandlers, IDestr target.useBitmapCoordinateSpace((scope: BitmapCoordinatesRenderingScope) => { this._drawBackground(scope); this._drawBorder(scope); + this._drawAdditionalSources(target, sourceBottomPaneViews); }); this._drawTickMarks(target); + this._drawAdditionalSources(target, sourcePaneViews); // atm we don't have sources to be drawn on time axis except crosshair which is rendered on top level canvas // so let's don't call this code at all for now // this._drawLabels(this._chart.model().dataSources(), target); @@ -312,7 +327,30 @@ export class TimeAxisWidget implements MouseEventHandlers, IDestr topTarget.useBitmapCoordinateSpace(({ context: ctx, bitmapSize }: BitmapCoordinatesRenderingScope) => { ctx.clearRect(0, 0, bitmapSize.width, bitmapSize.height); }); - this._drawLabels([this._chart.model().crosshairSource()], topTarget); + this._drawLabels([...this._chart.model().serieses(), this._chart.model().crosshairSource()], topTarget); + this._drawAdditionalSources(topTarget, sourceTopPaneViews); + } + } + + private _drawAdditionalSources(target: CanvasRenderingTarget2D, axisViewsGetter: ITimeAxisViewsGetter): void { + const sources = this._chart.model().serieses(); + + for (const source of sources) { + drawSourcePaneViews( + axisViewsGetter, + (renderer: IPaneRenderer) => drawBackground(renderer, target, false, undefined), + source, + undefined as unknown as Pane + ); + } + + for (const source of sources) { + drawSourcePaneViews( + axisViewsGetter, + (renderer: IPaneRenderer) => drawForeground(renderer, target, false, undefined), + source, + undefined as unknown as Pane + ); } } diff --git a/src/index.ts b/src/index.ts index 3dabfaf139..6cbd8c5fb1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,8 @@ /// +import { customStyleDefaults, seriesOptionsDefaults } from './api/options/series-options-defaults'; +import { CustomSeriesOptions } from './model/series-options'; + export { LineStyle, LineType } from './renderers/draw-line'; export { TrackingModeExitMode } from './model/chart-model'; @@ -9,10 +12,14 @@ export { PriceScaleMode } from './model/price-scale'; export { PriceLineSource, LastPriceAnimationMode } from './model/series-options'; export { ColorType } from './model/layout-options'; -export { createChart, createChartEx } from './api/create-chart'; - export { isBusinessDay, isUTCTimestamp } from './model/horz-scale-behavior-time/types'; export { TickMarkType } from './model/horz-scale-behavior-time/types'; +export const customSeriesDefaultOptions: CustomSeriesOptions = { + ...seriesOptionsDefaults, + ...customStyleDefaults, +}; + +export { createChart } from './api/create-chart'; /** * Returns the current version as a string. For example `'3.3.0'`. diff --git a/src/model/chart-model.ts b/src/model/chart-model.ts index 5afcf904b4..fda1a32534 100644 --- a/src/model/chart-model.ts +++ b/src/model/chart-model.ts @@ -14,6 +14,7 @@ import { Coordinate } from './coordinate'; import { Crosshair, CrosshairOptions } from './crosshair'; import { DefaultPriceScaleId, isDefaultPriceScale } from './default-price-scale'; import { GridOptions } from './grid'; +import { ICustomSeriesPaneView } from './icustom-series'; import { IHorzScaleBehavior } from './ihorz-scale-behavior'; import { InvalidateMask, InvalidationLevel, ITimeScaleAnimation } from './invalidate-mask'; import { IPriceDataSource } from './iprice-data-source'; @@ -829,6 +830,7 @@ export class ChartModel implements IDestroyable, IChartModelBase // to avoid memleaks this._options.localization.priceFormatter = undefined; + this._options.localization.percentageFormatter = undefined; this._options.localization.timeFormatter = undefined; } @@ -844,9 +846,9 @@ export class ChartModel implements IDestroyable, IChartModelBase return this._priceScalesOptionsChanged; } - public createSeries(seriesType: T, options: SeriesOptionsMap[T]): Series { + public createSeries(seriesType: T, options: SeriesOptionsMap[T], customPaneView?: ICustomSeriesPaneView): Series { const pane = this._panes[0]; - const series = this._createSeries(options, seriesType, pane); + const series = this._createSeries(options, seriesType, pane, customPaneView); this._serieses.push(series); if (this._serieses.length === 1) { @@ -1004,8 +1006,8 @@ export class ChartModel implements IDestroyable, IChartModelBase this._panes.forEach((pane: Pane) => pane.grid().paneView().update()); } - private _createSeries(options: SeriesOptionsInternal, seriesType: T, pane: Pane): Series { - const series = new Series(this, options, seriesType); + private _createSeries(options: SeriesOptionsInternal, seriesType: T, pane: Pane, customPaneView?: ICustomSeriesPaneView): Series { + const series = new Series(this, options, seriesType, pane, customPaneView); const targetScaleId = options.priceScaleId !== undefined ? options.priceScaleId : this.defaultVisiblePriceScaleId(); pane.addDataSource(series, targetScaleId); diff --git a/src/model/data-consumer.ts b/src/model/data-consumer.ts index 00c9bb1169..f189ebcc82 100644 --- a/src/model/data-consumer.ts +++ b/src/model/data-consumer.ts @@ -1,4 +1,5 @@ import { Time } from './horz-scale-behavior-time/types'; +import { CustomData, CustomSeriesWhitespaceData } from './icustom-series'; import { Series } from './series'; import { SeriesType } from './series-options'; @@ -23,12 +24,18 @@ export interface WhitespaceData { * The time of the data. */ time: HorzScaleItem; + + /** + * Additional custom values which will be ignored by the library, but + * could be used by plugins. + */ + customValues?: Record; } /** * A base interface for a data point of single-value series. */ -export interface SingleValueData { +export interface SingleValueData extends WhitespaceData { /** * The time of the data. */ @@ -118,7 +125,7 @@ export interface BaselineData extends SingleValueData { +export interface OhlcData extends WhitespaceData { /** * The bar time. */ @@ -213,6 +220,10 @@ export interface SeriesDataItemTypeMap { * The types of histogram series data. */ Histogram: HistogramData | WhitespaceData; + /** + * The base types of an custom series data. + */ + Custom: CustomData | CustomSeriesWhitespaceData; } export interface DataUpdatesConsumer { diff --git a/src/model/data-layer.ts b/src/model/data-layer.ts index 4618f3943b..d665d54e99 100644 --- a/src/model/data-layer.ts +++ b/src/model/data-layer.ts @@ -177,19 +177,15 @@ export class DataLayer { let seriesRows: (SeriesPlotRow | WhitespacePlotRow)[] = []; if (data.length !== 0) { - const extendedData = data as SeriesDataItemWithOriginalTime[]; - extendedData.forEach((i: SeriesDataItemWithOriginalTime) => saveOriginalTime(i)); - - // convertStringsToBusinessDays(data); - this._horzScaleBehavior.preprocessData(data); - - // const timeConverter = ensureNotNull(selectTimeConverter(data)); + const originalTimes = data.map((d: SeriesDataItemTypeMap[TSeriesType]) => d.time); const timeConverter = this._horzScaleBehavior.createConverterToInternalObj(data); const createPlotRow = getSeriesPlotRowCreator(series.seriesType()); + const dataToPlotRow = series.customSeriesPlotValuesBuilder(); + const customWhitespaceChecker = series.customSeriesWhitespaceCheck(); - seriesRows = extendedData.map((item: SeriesDataItemWithOriginalTime) => { + seriesRows = data.map((item: SeriesDataItemTypeMap[TSeriesType], index: number) => { const time = timeConverter(item.time); const horzItemKey = this._horzScaleBehavior.key(time); @@ -202,7 +198,7 @@ export class DataLayer { isTimeScaleAffected = true; } - const row = createPlotRow(time, timePointData.index, item, item.originalTime); + const row = createPlotRow(time, timePointData.index, item, originalTimes[index], dataToPlotRow, customWhitespaceChecker); timePointData.mapping.set(series, row); return row; }); @@ -251,7 +247,6 @@ export class DataLayer { saveOriginalTime(extendedData); // convertStringToBusinessDay(data); this._horzScaleBehavior.preprocessData(data); - const timeConverter = this._horzScaleBehavior.createConverterToInternalObj([data]); const time = timeConverter(data.time); @@ -274,7 +269,10 @@ export class DataLayer { } const createPlotRow = getSeriesPlotRowCreator(series.seriesType()); - const plotRow = createPlotRow(time, pointDataAtTime.index, data, extendedData.originalTime); + const dataToPlotRow = series.customSeriesPlotValuesBuilder(); + const customWhitespaceChecker = series.customSeriesWhitespaceCheck(); + const plotRow = createPlotRow(time, pointDataAtTime.index, data, extendedData.originalTime, dataToPlotRow, customWhitespaceChecker); + pointDataAtTime.mapping.set(series, plotRow); this._updateLastSeriesRow(series, plotRow); diff --git a/src/model/data-validators.ts b/src/model/data-validators.ts index a49064c282..795c740c44 100644 --- a/src/model/data-validators.ts +++ b/src/model/data-validators.ts @@ -45,7 +45,7 @@ export function checkSeriesValuesType(type: SeriesType, data: rea type Checker = (item: SeriesDataItemTypeMap[SeriesType]) => void; -function getChecker(type: SeriesType): Checker { +export function getChecker(type: SeriesType): Checker { switch (type) { case 'Bar': case 'Candlestick': @@ -56,6 +56,9 @@ function getChecker(type: SeriesType): Checker { case 'Line': case 'Histogram': return checkLineItem.bind(null, type); + + case 'Custom': + return checkCustomItem.bind(null, type); } } @@ -113,3 +116,11 @@ function checkLineItem( }` ); } + +function checkCustomItem( + // type: 'Custom', + // customItem: SeriesDataItemTypeMap[typeof type] +): void { + // Nothing to check yet... + return; +} diff --git a/src/model/get-series-plot-row-creator.ts b/src/model/get-series-plot-row-creator.ts index c7182397e7..0e740b5d04 100644 --- a/src/model/get-series-plot-row-creator.ts +++ b/src/model/get-series-plot-row-creator.ts @@ -1,14 +1,16 @@ - +import { ensureDefined } from '../helpers/assertions'; import { Mutable } from '../helpers/mutable'; -import { AreaData, BarData, BaselineData, CandlestickData, HistogramData, isWhitespaceData, LineData, SeriesDataItemTypeMap } from './data-consumer'; +import { CustomData } from '../model/icustom-series'; +import { PlotRow, PlotRowValue } from '../model/plot-data'; +import { SeriesPlotRow } from '../model/series-data'; +import { SeriesType } from '../model/series-options'; +import { TimePointIndex } from '../model/time-data'; + +import { AreaData, BarData, BaselineData, CandlestickData, HistogramData, isWhitespaceData, LineData, SeriesDataItemTypeMap, WhitespaceData } from './data-consumer'; import { InternalHorzScaleItem } from './ihorz-scale-behavior'; -import { PlotRow } from './plot-data'; -import { SeriesPlotRow } from './series-data'; -import { SeriesType } from './series-options'; -import { TimePointIndex } from './time-data'; -function getColoredLineBasedSeriesPlotRow(time: InternalHorzScaleItem, index: TimePointIndex, item: LineData | HistogramData, originalTime: HorzScaleItem): Mutable> { +function getColoredLineBasedSeriesPlotRow(time: InternalHorzScaleItem, index: TimePointIndex, item: LineData | HistogramData, originalTime: HorzScaleItem): Mutable> { const val = item.value; const res: Mutable> = { index, time, value: [val, val, val, val], originalTime }; @@ -20,7 +22,7 @@ function getColoredLineBasedSeriesPlotRow(time: InternalHorzScale return res; } -function getAreaSeriesPlotRow(time: InternalHorzScaleItem, index: TimePointIndex, item: AreaData, originalTime: HorzScaleItem): Mutable> { +function getAreaSeriesPlotRow(time: InternalHorzScaleItem, index: TimePointIndex, item: AreaData, originalTime: HorzScaleItem): Mutable> { const val = item.value; const res: Mutable> = { index, time, value: [val, val, val, val], originalTime }; @@ -40,7 +42,7 @@ function getAreaSeriesPlotRow(time: InternalHorzScaleItem, index: return res; } -function getBaselineSeriesPlotRow(time: InternalHorzScaleItem, index: TimePointIndex, item: BaselineData, originalTime: HorzScaleItem): Mutable> { +function getBaselineSeriesPlotRow(time: InternalHorzScaleItem, index: TimePointIndex, item: BaselineData, originalTime: HorzScaleItem): Mutable> { const val = item.value; const res: Mutable> = { index, time, value: [val, val, val, val], originalTime }; @@ -72,7 +74,7 @@ function getBaselineSeriesPlotRow(time: InternalHorzScaleItem, in return res; } -function getBarSeriesPlotRow(time: InternalHorzScaleItem, index: TimePointIndex, item: BarData, originalTime: HorzScaleItem): Mutable> { +function getBarSeriesPlotRow(time: InternalHorzScaleItem, index: TimePointIndex, item: BarData, originalTime: HorzScaleItem): Mutable> { const res: Mutable> = { index, time, value: [item.open, item.high, item.low, item.close], originalTime }; if (item.color !== undefined) { @@ -82,7 +84,7 @@ function getBarSeriesPlotRow(time: InternalHorzScaleItem, index: return res; } -function getCandlestickSeriesPlotRow(time: InternalHorzScaleItem, index: TimePointIndex, item: CandlestickData, originalTime: HorzScaleItem): Mutable> { +function getCandlestickSeriesPlotRow(time: InternalHorzScaleItem, index: TimePointIndex, item: CandlestickData, originalTime: HorzScaleItem): Mutable> { const res: Mutable> = { index, time, value: [item.open, item.high, item.low, item.close], originalTime }; if (item.color !== undefined) { res.color = item.color; @@ -99,35 +101,66 @@ function getCandlestickSeriesPlotRow(time: InternalHorzScaleItem, return res; } +// The returned data is used for scaling the series, and providing the current value for the price scale +export type CustomDataToPlotRowValueConverter = (item: CustomData | WhitespaceData) => number[]; + +function getCustomSeriesPlotRow(time: InternalHorzScaleItem, index: TimePointIndex, item: CustomData | WhitespaceData, originalTime: HorzScaleItem, dataToPlotRow?: CustomDataToPlotRowValueConverter): Mutable> { + const values = ensureDefined(dataToPlotRow)(item); + const max = Math.max(...values); + const min = Math.min(...values); + const last = values[values.length - 1]; + const value: PlotRowValue = [last, max, min, last]; + const { time: excludedTime, color, ...data } = item as CustomData; + return { index, time, value, originalTime, data, color }; +} + export type WhitespacePlotRow = Omit; -export function isSeriesPlotRow(row: SeriesPlotRow | WhitespacePlotRow): row is SeriesPlotRow { - return (row as Partial>).value !== undefined; +export function isSeriesPlotRow(row: SeriesPlotRow | WhitespacePlotRow): row is SeriesPlotRow { + return (row as Partial).value !== undefined; } -type SeriesItemValueFnMap = { - [T in keyof SeriesDataItemTypeMap]: (time: InternalHorzScaleItem, index: TimePointIndex, item: SeriesDataItemTypeMap[T], originalTime: HorzScaleItem) => Mutable | WhitespacePlotRow>; +type SeriesItemValueFnMap = { + [T in keyof SeriesDataItemTypeMap]: (time: InternalHorzScaleItem, index: TimePointIndex, item: SeriesDataItemTypeMap[T], originalTime: HorzScaleItem, dataToPlotRow?: CustomDataToPlotRowValueConverter, customIsWhitespace?: WhitespaceCheck) => Mutable | WhitespacePlotRow>; }; -function wrapWhitespaceData(createPlotRowFn: (typeof getBaselineSeriesPlotRow) | (typeof getBarSeriesPlotRow) | (typeof getCandlestickSeriesPlotRow)): SeriesItemValueFnMap[TSeriesType] { - return (time: InternalHorzScaleItem, index: TimePointIndex, bar: SeriesDataItemTypeMap[SeriesType], originalTime: HorzScaleItem) => { - if (isWhitespaceData(bar)) { - return { time, index, originalTime }; +function wrapCustomValues(plotRow: Mutable, bar: SeriesDataItemTypeMap[SeriesType]): Mutable { + if (bar.customValues !== undefined) { + plotRow.customValues = bar.customValues; + } + return plotRow; +} + +export type WhitespaceCheck = (bar: SeriesDataItemTypeMap[SeriesType]) => bar is WhitespaceData; + +function isWhitespaceDataWithCustomCheck(bar: SeriesDataItemTypeMap[SeriesType], customIsWhitespace?: WhitespaceCheck): bar is WhitespaceData { + if (customIsWhitespace) { + return customIsWhitespace(bar); + } + return isWhitespaceData(bar); +} + +type GetPlotRowType = (typeof getBaselineSeriesPlotRow) | (typeof getBarSeriesPlotRow) | (typeof getCandlestickSeriesPlotRow) | (typeof getCustomSeriesPlotRow); + +function wrapWhitespaceData(createPlotRowFn: GetPlotRowType): SeriesItemValueFnMap[TSeriesType] { + return (time: InternalHorzScaleItem, index: TimePointIndex, bar: SeriesDataItemTypeMap[SeriesType], originalTime: HorzScaleItem, dataToPlotRow?: CustomDataToPlotRowValueConverter, customIsWhitespace?: WhitespaceCheck) => { + if (isWhitespaceDataWithCustomCheck(bar, customIsWhitespace)) { + return wrapCustomValues({ time, index, originalTime }, bar); } - return createPlotRowFn(time, index, bar, originalTime); + return wrapCustomValues, HorzScaleItem>(createPlotRowFn(time, index, bar, originalTime, dataToPlotRow), bar); }; } -export function getSeriesPlotRowCreator(seriesType: TSeriesType): SeriesItemValueFnMap[TSeriesType] { - const seriesPlotRowFnMap: SeriesItemValueFnMap = { +export function getSeriesPlotRowCreator(seriesType: TSeriesType): SeriesItemValueFnMap[TSeriesType] { + const seriesPlotRowFnMap: SeriesItemValueFnMap = { Candlestick: wrapWhitespaceData(getCandlestickSeriesPlotRow), Bar: wrapWhitespaceData(getBarSeriesPlotRow), Area: wrapWhitespaceData(getAreaSeriesPlotRow), Baseline: wrapWhitespaceData(getBaselineSeriesPlotRow), Histogram: wrapWhitespaceData(getColoredLineBasedSeriesPlotRow), Line: wrapWhitespaceData(getColoredLineBasedSeriesPlotRow), + Custom: wrapWhitespaceData(getCustomSeriesPlotRow), }; - return seriesPlotRowFnMap[seriesType]; } diff --git a/src/model/horz-scale-behavior-time/horz-scale-behavior-time.ts b/src/model/horz-scale-behavior-time/horz-scale-behavior-time.ts index 48dcd65349..7e1771510f 100644 --- a/src/model/horz-scale-behavior-time/horz-scale-behavior-time.ts +++ b/src/model/horz-scale-behavior-time/horz-scale-behavior-time.ts @@ -21,6 +21,9 @@ import { BusinessDay, isBusinessDay, isUTCTimestamp, TickMarkType, TickMarkWeigh type TimeConverter = (time: Time) => InternalHorzScaleItem; function businessDayConverter(time: Time): InternalHorzScaleItem { + if (isString(time)) { + time = stringToBusinessDay(time); + } if (!isBusinessDay(time)) { throw new Error('time must be of type BusinessDay'); } @@ -46,7 +49,7 @@ function selectTimeConverter(data: TimedData