diff --git a/.size-limit.js b/.size-limit.js index f5c70f02f7..a62edea0cc 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -8,7 +8,7 @@ export default [ { name: 'ESM', path: 'dist/lightweight-charts.production.mjs', - limit: '45.00 KB', + limit: '47.00 KB', import: '*', ignore: ['fancy-canvas'], brotli: true, @@ -30,7 +30,23 @@ export default [ brotli: true, }, { - name: 'ESM Standalone', + name: 'ESM createYieldCurveChart', + path: 'dist/lightweight-charts.production.mjs', + limit: '45.00 KB', + import: '{ createYieldCurveChart }', + ignore: ['fancy-canvas'], + brotli: true, + }, + { + name: 'ESM createOptionsChart', + path: 'dist/lightweight-charts.production.mjs', + limit: '45.00 KB', + import: '{ createOptionsChart }', + ignore: ['fancy-canvas'], + brotli: true, + }, + { + name: 'Standalone-ESM', path: 'dist/lightweight-charts.standalone.production.mjs', limit: '50.00 KB', import: '*', @@ -66,4 +82,12 @@ export default [ limit: '4.08 KB', brotli: true, }, + { + name: 'Plugin: UpDownMarkersPrimitive', + path: 'dist/lightweight-charts.production.mjs', + import: '{ UpDownMarkersPrimitive }', + ignore: ['fancy-canvas'], + limit: '2.50 KB', + brotli: true, + }, ]; diff --git a/src/api/chart-api.ts b/src/api/chart-api.ts index 59da92a5fc..48a3ebd648 100644 --- a/src/api/chart-api.ts +++ b/src/api/chart-api.ts @@ -118,6 +118,8 @@ function toInternalOptions(options: DeepPartial = Pick, 'priceScale'>; export class ChartApi implements IChartApiBase, DataUpdatesConsumer { + protected readonly _horzScaleBehavior: IHorzScaleBehavior; + private _chartWidget: ChartWidget; private _dataLayer: DataLayer; private readonly _seriesMap: Map, Series> = new Map(); @@ -129,7 +131,6 @@ export class ChartApi implements IChartApiBase, Da private readonly _timeScaleApi: TimeScaleApi; private readonly _panes: WeakMap> = new WeakMap(); - private readonly _horzScaleBehavior: IHorzScaleBehavior; public constructor(container: HTMLElement, horzScaleBehavior: IHorzScaleBehavior, options?: DeepPartial>) { this._dataLayer = new DataLayer(horzScaleBehavior); @@ -262,8 +263,8 @@ export class ChartApi implements IChartApiBase, Da this._sendUpdateToChart(this._dataLayer.setSeriesData(series, data)); } - public updateData(series: Series, data: SeriesDataItemTypeMap[TSeriesType]): void { - this._sendUpdateToChart(this._dataLayer.updateSeriesData(series, data)); + public updateData(series: Series, data: SeriesDataItemTypeMap[TSeriesType], historicalUpdate: boolean): void { + this._sendUpdateToChart(this._dataLayer.updateSeriesData(series, data, historicalUpdate)); } public subscribeClick(handler: MouseEventHandler): void { @@ -398,6 +399,7 @@ export class ChartApi implements IChartApiBase, Da model.updateTimeScale(update.timeScale.baseIndex, update.timeScale.points, update.timeScale.firstChangedPointIndex); update.series.forEach((value: SeriesChanges, series: Series) => series.setData(value.data, value.info)); + model.timeScale().recalculateIndicesWithData(); model.recalculateAllPanes(); } diff --git a/src/api/create-chart.ts b/src/api/create-chart.ts index d8ccf37d49..b1080a9fe4 100644 --- a/src/api/create-chart.ts +++ b/src/api/create-chart.ts @@ -9,6 +9,15 @@ import { IHorzScaleBehavior } from '../model/ihorz-scale-behavior'; import { ChartApi } from './chart-api'; import { IChartApiBase } from './ichart-api'; +export function fetchHtmlElement(container: string | HTMLElement): HTMLElement { + if (isString(container)) { + const element = document.getElementById(container); + assert(element !== null, `Cannot find element in DOM with id=${container}`); + return element; + } + return container; +} + /** * This function is the main entry point of the Lightweight Charting Library. If you are using time values * for the horizontal scale then it is recommended that you rather use the {@link createChart} function. @@ -26,15 +35,7 @@ export function createChartEx> ): IChartApiBase { - let htmlElement: HTMLElement; - if (isString(container)) { - const element = document.getElementById(container); - assert(element !== null, `Cannot find element in DOM with id=${container}`); - htmlElement = element; - } else { - htmlElement = container; - } - + const htmlElement = fetchHtmlElement(container); const res = new ChartApi(htmlElement, horzScaleBehavior, options); horzScaleBehavior.setOptions(res.options()); return res; diff --git a/src/api/create-options-chart.ts b/src/api/create-options-chart.ts new file mode 100644 index 0000000000..45a7790398 --- /dev/null +++ b/src/api/create-options-chart.ts @@ -0,0 +1,25 @@ +import { DeepPartial } from '../helpers/strict-type-checks'; + +import { HorzScaleBehaviorPrice } from '../model/horz-scale-behavior-price/horz-scale-behaviour-price'; +import { PriceChartOptions } from '../model/horz-scale-behavior-price/options'; + +import { createChartEx } from './create-chart'; +import { IChartApiBase } from './ichart-api'; + +/** + * Creates an 'options' chart with price values on the horizontal scale. + * + * This function is used to create a specialized chart type where the horizontal scale + * represents price values instead of time. It's particularly useful for visualizing + * option chains, price distributions, or any data where price is the primary x-axis metric. + * + * @param container - The DOM element or its id where the chart will be rendered. + * @param options - Optional configuration options for the price chart. + * @returns An instance of IChartApiBase configured for price-based horizontal scaling. + */ +export function createOptionsChart( + container: string | HTMLElement, + options?: DeepPartial +): IChartApiBase { + return createChartEx(container, new HorzScaleBehaviorPrice(), options); +} diff --git a/src/api/create-yield-curve-chart.ts b/src/api/create-yield-curve-chart.ts new file mode 100644 index 0000000000..79cfad3f64 --- /dev/null +++ b/src/api/create-yield-curve-chart.ts @@ -0,0 +1,31 @@ +import { DeepPartial } from '../helpers/strict-type-checks'; + +import { + YieldCurveChartOptions, +} from '../model/yield-curve-horz-scale-behavior/yield-curve-chart-options'; + +import { fetchHtmlElement } from './create-chart'; +import { IYieldCurveChartApi } from './iyield-chart-api'; +import { YieldChartApi } from './yield-chart-api'; + +/** + * Creates a yield curve chart with the specified options. + * + * A yield curve chart differs from the default chart type + * in the following ways: + * - Horizontal scale is linearly spaced, and defined in monthly + * time duration units + * - Whitespace is ignored for the crosshair and grid lines + * + * @param container - ID of HTML element or element itself + * @param options - The yield chart options. + * @returns An interface to the created chart + */ +export function createYieldCurveChart( + container: string | HTMLElement, + options?: DeepPartial +): IYieldCurveChartApi { + const htmlElement = fetchHtmlElement(container); + const chartApi = new YieldChartApi(htmlElement, options); + return chartApi; +} diff --git a/src/api/iseries-api.ts b/src/api/iseries-api.ts index 665d218b94..140075d4a7 100644 --- a/src/api/iseries-api.ts +++ b/src/api/iseries-api.ts @@ -157,6 +157,8 @@ export interface ISeriesApi< * * @param bar - A single data item to be added. Time of the new item must be greater or equal to the latest existing time point. * If the new item's time is equal to the last existing item's time, then the existing item is replaced with the new one. + * @param historicalUpdate - If true, allows updating an existing data point that is not the latest bar. Default is false. + * Updating older data using `historicalUpdate` will be slower than updating the most recent data point. * @example Updating line series data * ```js * lineSeries.update({ @@ -175,7 +177,7 @@ export interface ISeriesApi< * }); * ``` */ - update(bar: TData): void; + update(bar: TData, historicalUpdate?: boolean): void; /** * Returns a bar data by provided logical index. diff --git a/src/api/iseries-primitive-api.ts b/src/api/iseries-primitive-api.ts index 98e664f2d2..9953014f99 100644 --- a/src/api/iseries-primitive-api.ts +++ b/src/api/iseries-primitive-api.ts @@ -1,4 +1,5 @@ import { Time } from '../model/horz-scale-behavior-time/types'; +import { IHorzScaleBehavior } from '../model/ihorz-scale-behavior'; import { ISeriesPrimitiveBase } from '../model/iseries-primitive'; import { SeriesOptionsMap, SeriesType } from '../model/series-options'; @@ -25,6 +26,10 @@ export interface SeriesAttachedParameter< * Request an update (redraw the chart) */ requestUpdate: () => void; + /** + * Horizontal Scale Behaviour for the chart. + */ + horzScaleBehavior: IHorzScaleBehavior; } /** diff --git a/src/api/iyield-chart-api.ts b/src/api/iyield-chart-api.ts new file mode 100644 index 0000000000..e94447e899 --- /dev/null +++ b/src/api/iyield-chart-api.ts @@ -0,0 +1,6 @@ +import { IChartApiBase } from './ichart-api'; + +/** + * The main interface of a single yield curve chart. + */ +export interface IYieldCurveChartApi extends IChartApiBase {} diff --git a/src/api/options/time-scale-options-defaults.ts b/src/api/options/time-scale-options-defaults.ts index d28982915e..f7bdb2c903 100644 --- a/src/api/options/time-scale-options-defaults.ts +++ b/src/api/options/time-scale-options-defaults.ts @@ -19,4 +19,5 @@ export const timeScaleOptionsDefaults: HorzScaleOptions = { uniformDistribution: false, minimumHeight: 0, allowBoldLabels: true, + ignoreWhitespaceIndices: false, }; diff --git a/src/api/options/yield-curve-chart-options-defaults.ts b/src/api/options/yield-curve-chart-options-defaults.ts new file mode 100644 index 0000000000..f7023bb014 --- /dev/null +++ b/src/api/options/yield-curve-chart-options-defaults.ts @@ -0,0 +1,7 @@ +import { YieldCurveOptions } from '../../model/yield-curve-horz-scale-behavior/yield-curve-chart-options'; + +export const yieldChartOptionsDefaults: YieldCurveOptions = { + baseResolution: 1, + minimumTimeRange: 120, + startTimeRange: 0, +}; diff --git a/src/api/series-api.ts b/src/api/series-api.ts index 64db5e5b1d..996a812f04 100644 --- a/src/api/series-api.ts +++ b/src/api/series-api.ts @@ -154,10 +154,10 @@ export class SeriesApi< this._onDataChanged('full'); } - public update(bar: TData): void { + public update(bar: TData, historicalUpdate: boolean = false): void { checkSeriesValuesType(this._series.seriesType(), [bar]); - this._dataUpdatesConsumer.updateData(this._series, bar); + this._dataUpdatesConsumer.updateData(this._series, bar, historicalUpdate); this._onDataChanged('update'); } @@ -223,6 +223,7 @@ export class SeriesApi< chart: this._chartApi, series: this, requestUpdate: () => this._series.model().fullUpdate(), + horzScaleBehavior: this._horzScaleBehavior, }); } } diff --git a/src/api/yield-chart-api.ts b/src/api/yield-chart-api.ts new file mode 100644 index 0000000000..32f18e07fb --- /dev/null +++ b/src/api/yield-chart-api.ts @@ -0,0 +1,128 @@ +import { DeepPartial, merge } from '../helpers/strict-type-checks'; + +import { AreaData, LineData, WhitespaceData } from '../model/data-consumer'; +import { + AreaSeriesOptions, + AreaStyleOptions, + LineSeriesOptions, + LineStyleOptions, + SeriesOptionsCommon, +} from '../model/series-options'; +import { YieldCurveChartOptions, YieldCurveOptions } from '../model/yield-curve-horz-scale-behavior/yield-curve-chart-options'; +import { YieldCurveHorzScaleBehavior } from '../model/yield-curve-horz-scale-behavior/yield-curve-horz-scale-behavior'; + +import { ChartApi } from './chart-api'; +import { ISeriesApi } from './iseries-api'; +import { IYieldCurveChartApi } from './iyield-chart-api'; +import { yieldChartOptionsDefaults } from './options/yield-curve-chart-options-defaults'; + +interface WhitespaceState { + start: number; + end: number; + resolution: number; +} + +function generateWhitespaceData({ + start, + end, + resolution, +}: WhitespaceState): WhitespaceData[] { + return Array.from( + { length: Math.floor((end - start) / resolution) + 1 }, + // eslint-disable-next-line quote-props + (item: unknown, i: number) => ({ 'time': start + i * resolution }) + ); +} + +function buildWhitespaceState( + options: YieldCurveOptions, + lastIndex: number +): WhitespaceState { + return { + start: Math.max(0, options.startTimeRange), + end: Math.max(0, options.minimumTimeRange, lastIndex || 0), + resolution: Math.max(1, options.baseResolution), + }; +} + +const generateWhitespaceHash = ({ + start, + end, + resolution, +}: WhitespaceState): string => `${start}~${end}~${resolution}`; + +const defaultOptions: DeepPartial = { + yieldCurve: yieldChartOptionsDefaults, + // and add sensible default options for yield charts which + // are different from the usual defaults. + timeScale: { + ignoreWhitespaceIndices: true, + }, + leftPriceScale: { + visible: true, + }, + rightPriceScale: { + visible: false, + }, + localization: { + priceFormatter: (value: number): string => { + return value.toFixed(3) + '%'; + }, + }, +}; + +const lineStyleDefaultOptionOverrides: DeepPartial = { + lastValueVisible: false, + priceLineVisible: false, +}; + +export class YieldChartApi extends ChartApi implements IYieldCurveChartApi { + public constructor(container: HTMLElement, options?: DeepPartial) { + const fullOptions = merge( + defaultOptions, + options || {} + ) as YieldCurveChartOptions; + const horzBehaviour = new YieldCurveHorzScaleBehavior(); + super(container, horzBehaviour, fullOptions); + horzBehaviour.setOptions(this.options() as YieldCurveChartOptions); + this._initWhitespaceSeries(); + } + + public override addLineSeries(options?: DeepPartial | undefined, paneIndex?: number | undefined): ISeriesApi<'Line', number, WhitespaceData | LineData, LineSeriesOptions, DeepPartial> { + const optionOverrides = { + ...lineStyleDefaultOptionOverrides, + ...options, + }; + return super.addLineSeries(optionOverrides, paneIndex); + } + + public override addAreaSeries(options?: DeepPartial | undefined, paneIndex?: number | undefined): ISeriesApi<'Area', number, AreaData | WhitespaceData, AreaSeriesOptions, DeepPartial> { + const optionOverrides = { + ...lineStyleDefaultOptionOverrides, + ...options, + }; + return super.addAreaSeries(optionOverrides, paneIndex); + } + + private _initWhitespaceSeries(): void { + const horzBehaviour = this._horzScaleBehavior as YieldCurveHorzScaleBehavior; + const whiteSpaceSeries = this.addLineSeries(); + + let currentWhitespaceHash: string; + function updateWhitespace(lastIndex: number): void { + const newWhitespaceState = buildWhitespaceState( + horzBehaviour.options().yieldCurve, + lastIndex + ); + const newWhitespaceHash = generateWhitespaceHash(newWhitespaceState); + + if (newWhitespaceHash !== currentWhitespaceHash) { + currentWhitespaceHash = newWhitespaceHash; + whiteSpaceSeries.setData(generateWhitespaceData(newWhitespaceState)); + } + } + + updateWhitespace(0); + horzBehaviour.whitespaceInvalidated().subscribe(updateWhitespace); + } +} diff --git a/src/index.ts b/src/index.ts index df8518928a..b54691e452 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,10 @@ export const customSeriesDefaultOptions: CustomSeriesOptions = { }; export { createChart, createChartEx, defaultHorzScaleBehavior } from './api/create-chart'; +export { createYieldCurveChart } from './api/create-yield-curve-chart'; +export { createOptionsChart } from './api/create-options-chart'; + +export { UpDownMarkersPrimitive } from './plugins/up-down-markers-plugin/primitive'; /* Plugins diff --git a/src/model/chart-model.ts b/src/model/chart-model.ts index 630307fa18..98737535ce 100644 --- a/src/model/chart-model.ts +++ b/src/model/chart-model.ts @@ -773,7 +773,7 @@ export class ChartModel implements IDestroyable, IChartModelBase public setAndSaveCurrentPosition(x: Coordinate, y: Coordinate, event: TouchMouseEventData | null, pane: Pane, skipEvent?: boolean): void { this._crosshair.saveOriginCoord(x, y); let price = NaN; - let index = this._timeScale.coordinateToIndex(x); + let index = this._timeScale.coordinateToIndex(x, true); const visibleBars = this._timeScale.visibleStrictRange(); if (visibleBars !== null) { @@ -927,6 +927,9 @@ export class ChartModel implements IDestroyable, IChartModelBase if (series.destroy) { series.destroy(); } + + this._timeScale.recalculateIndicesWithData(); + this._cleanupIfPaneIsEmpty(paneImpl); } diff --git a/src/model/data-consumer.ts b/src/model/data-consumer.ts index f189ebcc82..8fd3caa53f 100644 --- a/src/model/data-consumer.ts +++ b/src/model/data-consumer.ts @@ -228,5 +228,5 @@ export interface SeriesDataItemTypeMap { export interface DataUpdatesConsumer { applyNewData(series: Series, data: SeriesDataItemTypeMap[TSeriesType][]): void; - updateData(series: Series, data: SeriesDataItemTypeMap[TSeriesType]): void; + updateData(series: Series, data: SeriesDataItemTypeMap[TSeriesType], historicalUpdate: boolean): void; } diff --git a/src/model/data-layer.ts b/src/model/data-layer.ts index b1b9af0cc7..515cc7a8cd 100644 --- a/src/model/data-layer.ts +++ b/src/model/data-layer.ts @@ -96,6 +96,7 @@ function seriesUpdateInfo(seriesR const prevFirstAndLastTime = seriesRowsFirstAndLastTime(prevSeriesRows, bh); if (firstAndLastTime !== undefined && prevFirstAndLastTime !== undefined) { return { + historicalUpdate: false, lastBarUpdatedOrNewBarsAddedToTheRight: firstAndLastTime.lastTime >= prevFirstAndLastTime.lastTime && firstAndLastTime.firstTime >= prevFirstAndLastTime.firstTime, @@ -242,7 +243,7 @@ export class DataLayer { return this.setSeriesData(series, []); } - public updateSeriesData(series: Series, data: SeriesDataItemTypeMap[TSeriesType]): DataUpdateResponse { + public updateSeriesData(series: Series, data: SeriesDataItemTypeMap[TSeriesType], historicalUpdate: boolean): DataUpdateResponse { const extendedData = data as SeriesDataItemWithOriginalTime; saveOriginalTime(extendedData); // convertStringToBusinessDay(data); @@ -252,13 +253,17 @@ export class DataLayer { const time = timeConverter(data.time); const lastSeriesTime = this._seriesLastTimePoint.get(series); - if (lastSeriesTime !== undefined && this._horzScaleBehavior.key(time) < this._horzScaleBehavior.key(lastSeriesTime)) { + if (!historicalUpdate && lastSeriesTime !== undefined && this._horzScaleBehavior.key(time) < this._horzScaleBehavior.key(lastSeriesTime)) { // eslint-disable-next-line @typescript-eslint/no-base-to-string throw new Error(`Cannot update oldest data, last time=${lastSeriesTime}, new time=${time}`); } let pointDataAtTime = this._pointDataByTimePoint.get(this._horzScaleBehavior.key(time)); + if (historicalUpdate && pointDataAtTime === undefined) { + throw new Error('Cannot update non-existing data point when historicalUpdate is true'); + } + // if no point data found for the new data item // that means that we need to update scale const affectsTimeScale = pointDataAtTime === undefined; @@ -276,9 +281,16 @@ export class DataLayer { pointDataAtTime.mapping.set(series, plotRow); - this._updateLastSeriesRow(series, plotRow); + if (historicalUpdate) { + this._updateHistoricalSeriesRow(series, plotRow, pointDataAtTime.index); + } else { + this._updateLastSeriesRow(series, plotRow); + } - const info: SeriesUpdateInfo = { lastBarUpdatedOrNewBarsAddedToTheRight: isSeriesPlotRow(plotRow) }; + const info: SeriesUpdateInfo = { + lastBarUpdatedOrNewBarsAddedToTheRight: isSeriesPlotRow(plotRow), + historicalUpdate, + }; // if point already exist on the time scale - we don't need to make a full update and just make an incremental one if (!affectsTimeScale) { @@ -332,6 +344,22 @@ export class DataLayer { this._seriesLastTimePoint.set(series, plotRow.time); } + private _updateHistoricalSeriesRow(series: Series, plotRow: SeriesPlotRow | WhitespacePlotRow, pointDataIndex: number): void { + const seriesData = this._seriesRowsBySeries.get(series); + if (seriesData === undefined) { + return; + } + // binary search for actual index in array. + const index = lowerBound(seriesData, pointDataIndex, (row: SeriesPlotRow, currentIndex: number): boolean => + row.index < currentIndex + ); + if (isSeriesPlotRow(plotRow)) { + seriesData[index] = plotRow; + } else { + seriesData.splice(index, 1); + } + } + private _setRowsToSeries(series: Series, seriesRows: (SeriesPlotRow | WhitespacePlotRow)[]): void { if (seriesRows.length !== 0) { this._seriesRowsBySeries.set(series, seriesRows.filter(isSeriesPlotRow)); diff --git a/src/model/horz-scale-behavior-price/horz-scale-behaviour-price.ts b/src/model/horz-scale-behavior-price/horz-scale-behaviour-price.ts new file mode 100644 index 0000000000..eedd3523b2 --- /dev/null +++ b/src/model/horz-scale-behavior-price/horz-scale-behaviour-price.ts @@ -0,0 +1,122 @@ +import { Mutable } from '../../helpers/mutable'; + +import { ChartOptionsImpl } from '../chart-model'; +import { SeriesDataItemTypeMap } from '../data-consumer'; +import { + DataItem, + HorzScaleItemConverterToInternalObj, + IHorzScaleBehavior, + InternalHorzScaleItem, + InternalHorzScaleItemKey, +} from '../ihorz-scale-behavior'; +import { LocalizationOptions } from '../localization-options'; +import { SeriesType } from '../series-options'; +import { TickMark } from '../tick-marks'; +import { TickMarkWeightValue, TimeScalePoint } from '../time-data'; +import { TimeMark } from '../time-scale'; +import { PriceChartLocalizationOptions } from './options'; +import { HorzScalePriceItem } from './types'; + +function markWithGreaterWeight(a: TimeMark, b: TimeMark): TimeMark { + return a.weight > b.weight ? a : b; +} + +export class HorzScaleBehaviorPrice implements IHorzScaleBehavior { + private _options!: ChartOptionsImpl; + + public options(): ChartOptionsImpl { + return this._options; + } + + public setOptions(options: ChartOptionsImpl): void { + this._options = options; + } + + public preprocessData( + data: DataItem | DataItem[] + ): void {} + + public updateFormatter(options: PriceChartLocalizationOptions): void { + if (!this._options) { + return; + } + this._options.localization = options; + } + + public createConverterToInternalObj( + data: SeriesDataItemTypeMap[SeriesType][] + ): HorzScaleItemConverterToInternalObj { + return (price: number) => price as unknown as InternalHorzScaleItem; + } + + public key( + internalItem: InternalHorzScaleItem | HorzScalePriceItem + ): InternalHorzScaleItemKey { + return internalItem as InternalHorzScaleItemKey; + } + + public cacheKey(internalItem: InternalHorzScaleItem): number { + return internalItem as unknown as number; + } + + public convertHorzItemToInternal( + item: HorzScalePriceItem + ): InternalHorzScaleItem { + return item as unknown as InternalHorzScaleItem; + } + + public formatHorzItem(item: InternalHorzScaleItem): string { + return (item as unknown as number).toFixed(this._precision()); + } + + public formatTickmark( + item: TickMark, + localizationOptions: LocalizationOptions + ): string { + return (item.time as unknown as number).toFixed(this._precision()); + } + + public maxTickMarkWeight(marks: TimeMark[]): TickMarkWeightValue { + return marks.reduce(markWithGreaterWeight, marks[0]).weight; + } + + public fillWeightsForPoints( + sortedTimePoints: readonly Mutable[], + startIndex: number + ): void { + const priceWeight = (price: number) => { + if (price === Math.ceil(price / 100) * 100) { + return 8; + } + if (price === Math.ceil(price / 50) * 50) { + return 7; + } + if (price === Math.ceil(price / 25) * 25) { + return 6; + } + if (price === Math.ceil(price / 10) * 10) { + return 5; + } + if (price === Math.ceil(price / 5) * 5) { + return 4; + } + if (price === Math.ceil(price)) { + return 3; + } + if (price * 2 === Math.ceil(price * 2)) { + return 1; + } + return 0; + }; + for (let index = startIndex; index < sortedTimePoints.length; ++index) { + sortedTimePoints[index].timeWeight = priceWeight( + sortedTimePoints[index].time as unknown as number + ) as TickMarkWeightValue; + } + } + + private _precision(): number { + return (this._options.localization as PriceChartLocalizationOptions) + .precision; + } +} diff --git a/src/model/horz-scale-behavior-price/options.ts b/src/model/horz-scale-behavior-price/options.ts new file mode 100644 index 0000000000..ebc4c3b109 --- /dev/null +++ b/src/model/horz-scale-behavior-price/options.ts @@ -0,0 +1,26 @@ +import { ChartOptionsImpl } from '../chart-model'; +import { LocalizationOptions } from '../localization-options'; +import { HorzScalePriceItem } from './types'; + +/** + * Extends LocalizationOptions for price-based charts. + * Includes settings specific to formatting price values on the horizontal scale. + */ +export interface PriceChartLocalizationOptions + extends LocalizationOptions { + /** + * The number of decimal places to display for price values on the horizontal scale. + */ + precision: number; +} + +/** + * Configuration options specific to price-based charts. + * Extends the base chart options and includes localization settings for price formatting. + */ +export interface PriceChartOptions extends ChartOptionsImpl { + /** + * Localization options for formatting price values and other chart elements. + */ + localization: PriceChartLocalizationOptions; +} diff --git a/src/model/horz-scale-behavior-price/types.ts b/src/model/horz-scale-behavior-price/types.ts new file mode 100644 index 0000000000..42bc90912c --- /dev/null +++ b/src/model/horz-scale-behavior-price/types.ts @@ -0,0 +1 @@ +export type HorzScalePriceItem = number; diff --git a/src/model/plot-list.ts b/src/model/plot-list.ts index 65145de1ac..67a8797712 100644 --- a/src/model/plot-list.ts +++ b/src/model/plot-list.ts @@ -41,6 +41,7 @@ export class PlotList { private _items: readonly PlotRowType[] = []; private _minMaxCache: Map> = new Map(); private _rowSearchCache: Map> = new Map(); + private _indices: readonly TimePointIndex[] = []; // @returns Last row public last(): PlotRowType | null { @@ -108,8 +109,13 @@ export class PlotList { public setData(plotRows: readonly PlotRowType[]): void { this._rowSearchCache.clear(); this._minMaxCache.clear(); - this._items = plotRows; + this._indices = plotRows.map((plotRow: PlotRowType) => plotRow.index); + } + + // TimePointIndex values for fulfilled data points + public indices(): readonly TimePointIndex[] { + return this._indices; } private _indexAt(offset: PlotRowIndex): TimePointIndex { diff --git a/src/model/price-scale.ts b/src/model/price-scale.ts index 0439b8aa58..25bed713e8 100644 --- a/src/model/price-scale.ts +++ b/src/model/price-scale.ts @@ -167,7 +167,8 @@ export interface PriceScaleOptions { /** * Indicates if this price scale visible. Ignored by overlay price scales. * - * @defaultValue `true` for the right price scale and `false` for the left + * @defaultValue `true` for the right price scale and `false` for the left. + * For the yield curve chart, the default is for the left scale to be visible. */ visible: boolean; diff --git a/src/model/series-options.ts b/src/model/series-options.ts index ea3fa9c211..83176ff70d 100644 --- a/src/model/series-options.ts +++ b/src/model/series-options.ts @@ -663,7 +663,7 @@ export interface SeriesOptionsCommon { /** * Visibility of the label with the latest visible price on the price scale. * - * @defaultValue `true` + * @defaultValue `true`, `false` for yield curve charts */ lastValueVisible: boolean; @@ -698,7 +698,7 @@ export interface SeriesOptionsCommon { /** * Show the price line. Price line is a horizontal line indicating the last price of the series. * - * @defaultValue `true` + * @defaultValue `true`, `false` for yield curve charts */ priceLineVisible: boolean; diff --git a/src/model/series.ts b/src/model/series.ts index b2697464a8..78d69af484 100644 --- a/src/model/series.ts +++ b/src/model/series.ts @@ -122,6 +122,7 @@ export interface SeriesDataAtTypeMap { export interface SeriesUpdateInfo { lastBarUpdatedOrNewBarsAddedToTheRight: boolean; + historicalUpdate: boolean; } // note that if would like to use `Omit` here - you can't due https://github.com/microsoft/TypeScript/issues/36981 @@ -138,6 +139,7 @@ export interface ISeries extends IPriceDataSource { barColorer(): ISeriesBarColorer; markerDataAtIndex(index: TimePointIndex): MarkerData | null; dataAt(time: TimePointIndex): SeriesDataAtTypeMap[SeriesType] | null; + fulfilledIndices(): readonly TimePointIndex[]; } export class Series extends PriceDataSource implements IDestroyable, ISeries { @@ -552,6 +554,10 @@ export class Series extends PriceDataSource implements IDe }; } + public fulfilledIndices(): readonly TimePointIndex[] { + return this._data.indices(); + } + private _isOverlay(): boolean { const priceScale = this.priceScale(); return !isDefaultPriceScale(priceScale.id()); diff --git a/src/model/tick-marks.ts b/src/model/tick-marks.ts index 5cf1417170..6859dc1aa3 100644 --- a/src/model/tick-marks.ts +++ b/src/model/tick-marks.ts @@ -20,6 +20,8 @@ export interface TickMark { interface MarksCache { maxIndexesPerMark: number; + indicesWithDataId: number; + checkIndicesForData: boolean; marks: readonly TickMark[]; } @@ -55,11 +57,18 @@ export class TickMarks { } } - public build(spacing: number, maxWidth: number): readonly TickMark[] { + public build(spacing: number, maxWidth: number, checkIndicesForData: boolean, indicesWithDataMap: Map, indicesWithDataId: number): readonly TickMark[] { const maxIndexesPerMark = Math.ceil(maxWidth / spacing); - if (this._cache === null || this._cache.maxIndexesPerMark !== maxIndexesPerMark) { + if ( + this._cache === null || + this._cache.maxIndexesPerMark !== maxIndexesPerMark || + indicesWithDataId !== this._cache.indicesWithDataId || + checkIndicesForData !== this._cache.checkIndicesForData + ) { this._cache = { - marks: this._buildMarksImpl(maxIndexesPerMark), + indicesWithDataId, + checkIndicesForData, + marks: this._buildMarksImpl(maxIndexesPerMark, checkIndicesForData, indicesWithDataMap), maxIndexesPerMark, }; } @@ -91,9 +100,11 @@ export class TickMarks { } } - private _buildMarksImpl(maxIndexesPerMark: number): readonly TickMark[] { + private _buildMarksImpl(maxIndexesPerMark: number, checkIndicesForData: boolean, indicesWithDataMap: Map): readonly TickMark[] { let marks: TickMark[] = []; + const canBeIncluded = (mark: TickMark): boolean => !checkIndicesForData || indicesWithDataMap.has(mark.index); + for (const weight of Array.from(this._marksByWeight.keys()).sort((a: number, b: number) => b - a)) { if (!this._marksByWeight.get(weight)) { continue; @@ -119,7 +130,7 @@ export class TickMarks { while (prevMarksPointer < prevMarksLength) { const lastMark = prevMarks[prevMarksPointer]; const lastIndex = lastMark.index; - if (lastIndex < currentIndex) { + if (lastIndex < currentIndex && canBeIncluded(lastMark)) { prevMarksPointer++; marks.push(lastMark); leftIndex = lastIndex; @@ -130,7 +141,11 @@ export class TickMarks { } } - if (rightIndex - currentIndex >= maxIndexesPerMark && currentIndex - leftIndex >= maxIndexesPerMark) { + if ( + rightIndex - currentIndex >= maxIndexesPerMark && + currentIndex - leftIndex >= maxIndexesPerMark && + canBeIncluded(mark) + ) { // TickMark fits. Place it into new array marks.push(mark); leftIndex = currentIndex; @@ -143,7 +158,9 @@ export class TickMarks { // Place all unused tickMarks into new array; for (; prevMarksPointer < prevMarksLength; prevMarksPointer++) { - marks.push(prevMarks[prevMarksPointer]); + if (canBeIncluded(prevMarks[prevMarksPointer])) { + marks.push(prevMarks[prevMarksPointer]); + } } } diff --git a/src/model/time-scale.ts b/src/model/time-scale.ts index a02d237dbd..ee88fd1b14 100644 --- a/src/model/time-scale.ts +++ b/src/model/time-scale.ts @@ -202,6 +202,16 @@ export interface HorzScaleOptions { * @defaultValue true */ allowBoldLabels: boolean; + + /** + * Ignore time scale points containing only whitespace (for all series) when + * drawing grid lines, tick marks, and snapping the crosshair to time scale points. + * + * For the yield curve chart type it defaults to `true`. + * + * @defaultValue false + */ + ignoreWhitespaceIndices: boolean; } export interface ITimeScale { @@ -223,6 +233,8 @@ export interface ITimeScale { coordinateToIndex(x: Coordinate): TimePointIndex; options(): Readonly; + + recalculateIndicesWithData(): void; } export class TimeScale implements ITimeScale { @@ -250,6 +262,9 @@ export class TimeScale implements ITimeScale { private _commonTransitionStartState: TransitionState | null = null; private _timeMarksCache: TimeMark[] | null = null; + private _indicesWithData: Map = new Map(); + private _indicesWithDataUpdateId: number = -1; + private _labels: TimeMark[] = []; private readonly _horzScaleBehavior: IHorzScaleBehavior; @@ -266,6 +281,7 @@ export class TimeScale implements ITimeScale { this._updateDateTimeFormatter(); this._tickMarks.setUniformDistribution(options.uniformDistribution); + this.recalculateIndicesWithData(); } public options(): Readonly { @@ -306,6 +322,10 @@ export class TimeScale implements ITimeScale { this._model.setBarSpacing(options.barSpacing ?? this._barSpacing); } + if (options.ignoreWhitespaceIndices !== undefined && options.ignoreWhitespaceIndices !== this._options.ignoreWhitespaceIndices) { + this.recalculateIndicesWithData(); + } + this._invalidateTickMarks(); this._updateDateTimeFormatter(); this._optionsApplied.fire(); @@ -464,8 +484,16 @@ export class TimeScale implements ITimeScale { } } - public coordinateToIndex(x: Coordinate): TimePointIndex { - return Math.ceil(this._coordinateToFloatIndex(x)) as TimePointIndex; + public coordinateToIndex(x: Coordinate, considerIgnoreWhitespace?: boolean): TimePointIndex { + const index = Math.ceil(this._coordinateToFloatIndex(x)) as TimePointIndex; + if ( + !considerIgnoreWhitespace || + !this._options.ignoreWhitespaceIndices || + this._shouldConsiderIndex(index) + ) { + return index; + } + return this._findNearestIndexWithData(index); } public setRightOffset(offset: number): void { @@ -517,7 +545,13 @@ export class TimeScale implements ITimeScale { const firstBar = Math.max(visibleBars.left(), visibleBars.left() - indexPerLabel); const lastBar = Math.max(visibleBars.right(), visibleBars.right() - indexPerLabel); - const items = this._tickMarks.build(spacing, maxLabelWidth); + const items = this._tickMarks.build( + spacing, + maxLabelWidth, + this._options.ignoreWhitespaceIndices, + this._indicesWithData, + this._indicesWithDataUpdateId + ); // according to indexPerLabel value this value means "earliest index which _might be_ used as the second label on time scale" const earliestIndexOfSecondLabel = (this._firstIndex() as number) + indexPerLabel; @@ -775,6 +809,20 @@ export class TimeScale implements ITimeScale { return this._horzScaleBehavior.formatHorzItem(timeScalePoint.time); } + public recalculateIndicesWithData(): void { + if (!this._options.ignoreWhitespaceIndices) { + return; + } + this._indicesWithData.clear(); + const series = this._model.serieses(); + for (const s of series) { + for (const index of s.fulfilledIndices()) { + this._indicesWithData.set(index, true); + } + } + this._indicesWithDataUpdateId++; + } + private _isAllScalingAndScrollingDisabled(): boolean { const handleScroll = this._model.options()['handleScroll']; const handleScale = this._model.options()['handleScale']; @@ -996,4 +1044,43 @@ export class TimeScale implements ITimeScale { this._correctBarSpacing(); } + + private _shouldConsiderIndex(index: TimePointIndex): boolean { + if (!this._options.ignoreWhitespaceIndices) { + return true; + } + return this._indicesWithData.get(index) || false; + } + + private _findNearestIndexWithData(x: TimePointIndex): TimePointIndex { + const gen = testNearestIntegers(x); + const maxIndex = this._lastIndex(); + while (maxIndex) { + const index = gen.next().value as TimePointIndex; + if (this._indicesWithData.get(index)) { + return index; + } + if (index < 0 || index > maxIndex) { + break; + } + } + return x; // fallback to original index + } +} + +function* testNearestIntegers(num: number): Generator { + const rounded = Math.round(num); + const isRoundedDown = rounded < num; + let offset = 1; + + while (true) { + if (isRoundedDown) { + yield rounded + offset; + yield rounded - offset; + } else { + yield rounded - offset; + yield rounded + offset; + } + offset++; + } } diff --git a/src/model/yield-curve-horz-scale-behavior/yield-curve-chart-options.ts b/src/model/yield-curve-horz-scale-behavior/yield-curve-chart-options.ts new file mode 100644 index 0000000000..1c58c38b76 --- /dev/null +++ b/src/model/yield-curve-horz-scale-behavior/yield-curve-chart-options.ts @@ -0,0 +1,47 @@ +import { ChartOptionsImpl } from '../chart-model'; + +/** + * Options specific to yield curve charts. + */ +export interface YieldCurveOptions { + /** + * The smallest time unit for the yield curve, typically representing one month. + * This value determines the granularity of the time scale. + * @defaultValue 1 + */ + baseResolution: number; + + /** + * The minimum time range to be displayed on the chart, in units of baseResolution. + * This ensures that the chart always shows at least this much time range, even if there's less data. + * @defaultValue 120 (10 years) + */ + minimumTimeRange: number; + + /** + * The starting time value for the chart, in units of baseResolution. + * This determines where the time scale begins. + * @defaultValue 0 + */ + startTimeRange: number; + + /** + * Optional custom formatter for time values on the horizontal axis. + * If not provided, a default formatter will be used. + * @param months - The number of months (or baseResolution units) to format + * @returns A string representation of the time value + */ + formatTime?: (months: number) => string; +} + +/** + * Extended chart options that include yield curve specific options. + * This interface combines the standard chart options with yield curve options. + */ +export interface YieldCurveChartOptions extends ChartOptionsImpl { + /** + * Yield curve specific options. + * This object contains all the settings related to how the yield curve is displayed and behaves. + */ + yieldCurve: YieldCurveOptions; +} diff --git a/src/model/yield-curve-horz-scale-behavior/yield-curve-horz-scale-behavior.ts b/src/model/yield-curve-horz-scale-behavior/yield-curve-horz-scale-behavior.ts new file mode 100644 index 0000000000..cac86abf44 --- /dev/null +++ b/src/model/yield-curve-horz-scale-behavior/yield-curve-horz-scale-behavior.ts @@ -0,0 +1,177 @@ +import { Delegate } from '../../helpers/delegate'; +import { ISubscription } from '../../helpers/isubscription'; +import { Mutable } from '../../helpers/mutable'; + +import { SeriesDataItemTypeMap } from '../data-consumer'; +import { + DataItem, + HorzScaleItemConverterToInternalObj, + IHorzScaleBehavior, + InternalHorzScaleItem, + InternalHorzScaleItemKey, +} from '../ihorz-scale-behavior'; +import { LocalizationOptions } from '../localization-options'; +import { SeriesType } from '../series-options'; +import { TickMark } from '../tick-marks'; +import { TickMarkWeightValue, TimeScalePoint } from '../time-data'; +import { TimeMark } from '../time-scale'; +import { YieldCurveChartOptions } from './yield-curve-chart-options'; + +type EventHandler = (...args: unknown[]) => void; +function createDebouncedMicroTaskHandler(callback: EventHandler): EventHandler { + let scheduled = false; + + return function(...args: unknown[]): void { + if (!scheduled) { + scheduled = true; + + queueMicrotask((): void => { + callback(...args); + scheduled = false; + }); + } + }; +} + +function markWithGreaterWeight(a: TimeMark, b: TimeMark): TimeMark { + return a.weight > b.weight ? a : b; +} + +function toInternalHorzScaleItem( + item: number | InternalHorzScaleItem +): InternalHorzScaleItem { + return item as unknown as InternalHorzScaleItem; +} + +function fromInternalHorzScaleItem( + item: InternalHorzScaleItem | number +): number { + return item as unknown as number; +} + +export class YieldCurveHorzScaleBehavior implements IHorzScaleBehavior { + private _options!: YieldCurveChartOptions; + private readonly _pointsChangedDelegate: Delegate = new Delegate(); + private _invalidateWhitespace: EventHandler = createDebouncedMicroTaskHandler(() => this._pointsChangedDelegate.fire(this._largestIndex)); + private _largestIndex: number = 0; + + /** Data changes might require that the whitespace be generated again */ + public whitespaceInvalidated(): ISubscription { + return this._pointsChangedDelegate; + } + + public destroy(): void { + this._pointsChangedDelegate.destroy(); + } + + public options(): YieldCurveChartOptions { + return this._options; + } + + public setOptions(options: YieldCurveChartOptions): void { + this._options = options; + } + + public preprocessData(data: DataItem | DataItem[]): void { + // No preprocessing needed for yield curve data + } + + public updateFormatter(options: LocalizationOptions): void { + if (!this._options) { + return; + } + this._options.localization = options; + } + + public createConverterToInternalObj( + data: SeriesDataItemTypeMap[SeriesType][] + ): HorzScaleItemConverterToInternalObj { + this._invalidateWhitespace(); + return (time: number) => { + if (time > this._largestIndex) { + this._largestIndex = time; + } + return toInternalHorzScaleItem(time); + }; + } + + public key( + internalItem: InternalHorzScaleItem | number + ): InternalHorzScaleItemKey { + return internalItem as unknown as InternalHorzScaleItemKey; + } + + public cacheKey(internalItem: InternalHorzScaleItem): number { + return fromInternalHorzScaleItem(internalItem); + } + + public convertHorzItemToInternal(item: number): InternalHorzScaleItem { + return toInternalHorzScaleItem(item); + } + + public formatHorzItem(item: InternalHorzScaleItem): string { + return this._formatTime(item as unknown as number); + } + + public formatTickmark(item: TickMark): string { + return this._formatTime(item.time as unknown as number); + } + + public maxTickMarkWeight(marks: TimeMark[]): TickMarkWeightValue { + return marks.reduce(markWithGreaterWeight, marks[0]).weight; + } + + public fillWeightsForPoints( + sortedTimePoints: readonly Mutable[], + startIndex: number + ): void { + const timeWeight = (time: number) => { + if (time % 120 === 0) { + return 10; + } + if (time % 60 === 0) { + return 9; + } + if (time % 36 === 0) { + return 8; + } + if (time % 12 === 0) { + return 7; + } + if (time % 6 === 0) { + return 6; + } + if (time % 3 === 0) { + return 5; + } + if (time % 1 === 0) { + return 4; + } + return 0; + }; + + for (let index = startIndex; index < sortedTimePoints.length; ++index) { + sortedTimePoints[index].timeWeight = timeWeight( + fromInternalHorzScaleItem(sortedTimePoints[index].time) + ) as TickMarkWeightValue; + } + this._largestIndex = fromInternalHorzScaleItem(sortedTimePoints[sortedTimePoints.length - 1].time); + this._invalidateWhitespace(); + } + + private _formatTime(months: number): string { + if (this._options.localization?.timeFormatter) { + return this._options.localization.timeFormatter(months); + } + + if (months < 12) { + return `${months}M`; + } + const years = Math.floor(months / 12); + const remainingMonths = months % 12; + if (remainingMonths === 0) { + return `${years}Y`; + } + return `${years}Y${remainingMonths}M`; + } +} diff --git a/src/plugins/up-down-markers-plugin/expiring-markers-manager.ts b/src/plugins/up-down-markers-plugin/expiring-markers-manager.ts new file mode 100644 index 0000000000..ab33c15506 --- /dev/null +++ b/src/plugins/up-down-markers-plugin/expiring-markers-manager.ts @@ -0,0 +1,88 @@ +import { InternalHorzScaleItemKey } from '../../model/ihorz-scale-behavior'; + +import { SeriesUpDownMarker } from './types'; + +type MarkerWithTimeout = SeriesUpDownMarker & { + timeoutId?: number; + expiresAt?: number; +}; + +export class ExpiringMarkerManager { + private _markers: Map> = new Map(); + private _updateCallback: (() => void); + + public constructor(updateCallback: () => void) { + this._updateCallback = updateCallback; + } + + public setMarker(marker: SeriesUpDownMarker, key: InternalHorzScaleItemKey, timeout?: number): void { + this.clearMarker(key); + + if (timeout !== undefined) { + const timeoutId = window.setTimeout( + () => { + this._markers.delete(key); + this._triggerUpdate(); + }, + timeout + ); + + const markerWithTimeout: MarkerWithTimeout = { + ...marker, + timeoutId, + expiresAt: Date.now() + timeout, + }; + + this._markers.set(key, markerWithTimeout); + } else { + // For markers without timeout, we set timeoutId and expiresAt to undefined + this._markers.set(key, { + ...marker, + timeoutId: undefined, + expiresAt: undefined, + }); + } + + this._triggerUpdate(); + } + + public clearMarker(key: InternalHorzScaleItemKey): void { + const marker = this._markers.get(key); + if (marker && marker.timeoutId !== undefined) { + window.clearTimeout(marker.timeoutId); + } + this._markers.delete(key); + this._triggerUpdate(); + } + + public clearAllMarkers(): void { + for (const [point] of this._markers) { + this.clearMarker(point); + } + } + + public getMarkers(): SeriesUpDownMarker[] { + const now = Date.now(); + const activeMarkers: SeriesUpDownMarker[] = []; + + for (const [time, marker] of this._markers) { + if (!marker.expiresAt || marker.expiresAt > now) { + activeMarkers.push({ time: marker.time, sign: marker.sign, value: marker.value }); + } else { + this.clearMarker(time); + } + } + + return activeMarkers; + } + + public setUpdateCallback(callback: () => void): void { + this._updateCallback = callback; + } + + private _triggerUpdate(): void { + if (this._updateCallback) { + this._updateCallback(); + } + } +} diff --git a/src/plugins/up-down-markers-plugin/options.ts b/src/plugins/up-down-markers-plugin/options.ts new file mode 100644 index 0000000000..6aa3fc6c89 --- /dev/null +++ b/src/plugins/up-down-markers-plugin/options.ts @@ -0,0 +1,29 @@ +/** + * Configuration options for the UpDownMarkers plugin. + */ +export interface UpDownMarkersPluginOptions { + /** + * The color used for markers indicating a positive price change. + * This color will be applied to markers shown above data points where the price has increased. + */ + positiveColor: string; + + /** + * The color used for markers indicating a negative price change. + * This color will be applied to markers shown below data points where the price has decreased. + */ + negativeColor: string; + + /** + * The duration (in milliseconds) for which update markers remain visible on the chart. + * After this duration, the markers will automatically disappear. + * Set to 0 for markers to remain indefinitely until the next update. + */ + updateVisibilityDuration: number; +} + +export const upDownMarkersPluginOptionDefaults: UpDownMarkersPluginOptions = { + positiveColor: '#22AB94', + negativeColor: '#F7525F', + updateVisibilityDuration: 5000, +}; diff --git a/src/plugins/up-down-markers-plugin/primitive.ts b/src/plugins/up-down-markers-plugin/primitive.ts new file mode 100644 index 0000000000..90b974ef69 --- /dev/null +++ b/src/plugins/up-down-markers-plugin/primitive.ts @@ -0,0 +1,238 @@ +import { IChartApiBase } from '../../api/ichart-api'; +import { ISeriesApi } from '../../api/iseries-api'; +import { SeriesAttachedParameter } from '../../api/iseries-primitive-api'; + +import { ensureDefined } from '../../helpers/assertions'; + +import { isFulfilledData, isWhitespaceData, LineData, SeriesDataItemTypeMap } from '../../model/data-consumer'; +import { IHorzScaleBehavior, InternalHorzScaleItemKey } from '../../model/ihorz-scale-behavior'; +import { IPrimitivePaneView } from '../../model/ipane-primitive'; + +import { ExpiringMarkerManager } from './expiring-markers-manager'; +import { + upDownMarkersPluginOptionDefaults, + UpDownMarkersPluginOptions, +} from './options'; +import { SeriesUpDownMarker, UpDownMarkersSupportedSeriesTypes } from './types'; +import { MarkersPrimitivePaneView } from './view'; + +function isLineData( + item: SeriesDataItemTypeMap[UpDownMarkersSupportedSeriesTypes], + type: UpDownMarkersSupportedSeriesTypes +): item is LineData { + return type === 'Line' || type === 'Area'; +} + +/** + * UpDownMarkersPrimitive class for showing the direction of price changes on the chart. + * This plugin can only be used with Line and Area series types. + * 1. Manual control: + * + * - Use the `setMarkers` method to manually add markers to the chart. + * This will replace any existing markers. + * - Use `clearMarkers` to remove all markers. + * + * 2. Automatic updates: + * + * Use `setData` and `update` from this primitive instead of the those on the series to let the + * primitive handle the creation of price change markers automatically. + * + * - Use `setData` to initialize or replace all data points. + * - Use `update` to modify individual data points. This will automatically + * create markers for price changes on existing data points. + * - The `updateVisibilityDuration` option controls how long markers remain visible. + */ +export class UpDownMarkersPrimitive< + HorzScaleItem, + TData extends SeriesDataItemTypeMap[UpDownMarkersSupportedSeriesTypes] = SeriesDataItemTypeMap['Line'] +> { + private _chart: IChartApiBase | undefined = undefined; + private _series: ISeriesApi | undefined = + undefined; + private _paneViews: MarkersPrimitivePaneView< + HorzScaleItem, + UpDownMarkersSupportedSeriesTypes + >[] = []; + private _markersManager: ExpiringMarkerManager; + private _requestUpdate?: () => void; + private _horzScaleBehavior: IHorzScaleBehavior | null = null; + private _options: UpDownMarkersPluginOptions; + private _managedDataPoints: Map = new Map(); + + public constructor( + options: Partial + ) { + this._markersManager = new ExpiringMarkerManager(() => this.requestUpdate()); + this._options = { + ...upDownMarkersPluginOptionDefaults, + ...options, + }; + } + + /** + * Applies new options to the plugin. + * @param options - Partial options to apply. + */ + public applyOptions(options: Partial): void { + this._options = { + ...this._options, + ...options, + }; + this.requestUpdate(); + } + + /** + * Manually sets markers on the chart. + * @param markers - Array of SeriesUpDownMarker to set. + */ + public setMarkers(markers: SeriesUpDownMarker[]): void { + this._markersManager.clearAllMarkers(); + const horzBehaviour = this._horzScaleBehavior; + if (!horzBehaviour) { + return; + } + markers.forEach((marker: SeriesUpDownMarker) => { + this._markersManager.setMarker(marker, horzBehaviour.key(marker.time)); + }); + } + + /** + * Retrieves the current markers on the chart. + * @returns An array of SeriesUpDownMarker. + */ + public markers(): readonly SeriesUpDownMarker[] { + return this._markersManager.getMarkers(); + } + + /** + * Requests an update of the chart. + */ + public requestUpdate(): void { + this._requestUpdate?.(); + } + + /** + * Attaches the primitive to the chart and series. + * @param params - Parameters for attaching the primitive. + */ + public attached(params: SeriesAttachedParameter): void { + const { + chart, + series, + requestUpdate, + horzScaleBehavior, + } = params; + this._chart = chart; + this._series = series as ISeriesApi; + this._horzScaleBehavior = horzScaleBehavior; + const seriesType = this._series.seriesType(); + if (seriesType !== 'Area' && seriesType !== 'Line') { + throw new Error( + 'UpDownMarkersPrimitive is only supported for Area and Line series types' + ); + } + this._paneViews = [ + new MarkersPrimitivePaneView( + this._series, + this._chart.timeScale(), + this._options + ), + ]; + this._requestUpdate = requestUpdate; + this.requestUpdate(); + } + + /** + * Detaches the primitive from the chart and series. + */ + public detached(): void { + this._chart = undefined; + this._series = undefined; + this._requestUpdate = undefined; + } + + public chart(): IChartApiBase { + return ensureDefined(this._chart); + } + + public series(): ISeriesApi { + return ensureDefined(this._series); + } + + public updateAllViews(): void { + this._paneViews.forEach( + (pw: MarkersPrimitivePaneView) => + pw.update(this.markers()) + ); + } + + public paneViews(): readonly IPrimitivePaneView[] { + return this._paneViews; + } + + /** + * Sets the data for the series and manages data points for marker updates. + * @param data - Array of data points to set. + */ + public setData(data: TData[]): void { + if (!this._series) { + throw new Error('Primitive not attached to series'); + } + const seriesType = this._series.seriesType(); + this._managedDataPoints.clear(); + const horzBehaviour = this._horzScaleBehavior; + if (horzBehaviour) { + data.forEach((d: TData) => { + if (isFulfilledData(d) && isLineData(d, seriesType)) { + this._managedDataPoints.set(horzBehaviour.key(d.time), d.value); + } + }); + } + ensureDefined(this._series).setData(data); + } + + /** + * Updates a single data point and manages marker updates for existing data points. + * @param data - The data point to update. + * @param historicalUpdate - Optional flag for historical updates. + */ + public update(data: TData, historicalUpdate?: boolean): void { + if (!this._series || !this._horzScaleBehavior) { + throw new Error('Primitive not attached to series'); + } + const seriesType = this._series.seriesType(); + const horzKey = this._horzScaleBehavior.key(data.time); + if (isWhitespaceData(data)) { + this._managedDataPoints.delete(horzKey); + } + if (isFulfilledData(data) && isLineData(data, seriesType)) { + const existingPrice = this._managedDataPoints.get(horzKey); + if (existingPrice) { + this._markersManager.setMarker( + { + time: data.time, + value: data.value, + sign: getSign(data.value, existingPrice), + }, + horzKey, + this._options.updateVisibilityDuration + ); + } + } + ensureDefined(this._series).update(data, historicalUpdate); + } + + /** + * Clears all markers from the chart. + */ + public clearMarkers(): void { + this._markersManager.clearAllMarkers(); + } +} + +function getSign(newValue: number, oldValue: number): 1 | 0 | -1 { + if (newValue === oldValue) { + return 0; + } + return newValue - oldValue > 0 ? 1 : -1; +} diff --git a/src/plugins/up-down-markers-plugin/renderer.ts b/src/plugins/up-down-markers-plugin/renderer.ts new file mode 100644 index 0000000000..da4f71e9a4 --- /dev/null +++ b/src/plugins/up-down-markers-plugin/renderer.ts @@ -0,0 +1,94 @@ +import { + BitmapCoordinatesRenderingScope, + CanvasRenderingTarget2D, +} from 'fancy-canvas'; + +import { IPrimitivePaneRenderer } from '../../model/ipane-primitive'; + +import { MarkerCoordinates } from './types'; + +const enum Constants { + Radius = 4, + ArrowSize = 4.7, + ArrowOffset = 7, + ArrowLineWidth = 2, + VerticalScale = 0.5, +} + +export class MarkersPrimitiveRenderer implements IPrimitivePaneRenderer { + private _data: MarkerCoordinates[]; + private readonly _neutralColor: string; + private readonly _negativeColor: string; + private readonly _positiveColor: string; + + public constructor( + data: MarkerCoordinates[], + neutralColor: string, + negativeColor: string, + positiveColor: string + ) { + this._data = data; + this._neutralColor = neutralColor; + this._negativeColor = negativeColor; + this._positiveColor = positiveColor; + } + + public draw(target: CanvasRenderingTarget2D): void { + target.useBitmapCoordinateSpace( + (scope: BitmapCoordinatesRenderingScope) => { + const ctx = scope.context; + const tickWidth = Math.max(1, Math.floor(scope.horizontalPixelRatio)); + const correction = (tickWidth % 2) / 2; + + const rad = Constants.Radius * scope.verticalPixelRatio + correction; + this._data.forEach((item: MarkerCoordinates) => { + const centreX = Math.round(item.x * scope.horizontalPixelRatio) + correction; + ctx.beginPath(); + const color = this._getColor(item.sign); + ctx.fillStyle = color; + ctx.arc( + centreX, + item.y * scope.verticalPixelRatio, + rad, + 0, + 2 * Math.PI, + false + ); + ctx.fill(); + if (item.sign) { + ctx.strokeStyle = color; + ctx.lineWidth = Math.floor( + Constants.ArrowLineWidth * scope.horizontalPixelRatio + ); + ctx.beginPath(); + ctx.moveTo( + (item.x - Constants.ArrowSize) * scope.horizontalPixelRatio + correction, + (item.y - Constants.ArrowOffset * item.sign) * + scope.verticalPixelRatio + ); + ctx.lineTo( + item.x * scope.horizontalPixelRatio + correction, + (item.y - + Constants.ArrowOffset * item.sign - + Constants.ArrowOffset * item.sign * Constants.VerticalScale) * + scope.verticalPixelRatio + ); + ctx.lineTo( + (item.x + Constants.ArrowSize) * scope.horizontalPixelRatio + correction, + (item.y - Constants.ArrowOffset * item.sign) * + scope.verticalPixelRatio + ); + ctx.stroke(); + } + }); + } + ); + } + + private _getColor(sign: number): string { + if (sign === 0) { + return this._neutralColor; + } + return sign > 0 ? this._positiveColor : this._negativeColor; + } +} diff --git a/src/plugins/up-down-markers-plugin/types.ts b/src/plugins/up-down-markers-plugin/types.ts new file mode 100644 index 0000000000..90616ef069 --- /dev/null +++ b/src/plugins/up-down-markers-plugin/types.ts @@ -0,0 +1,48 @@ +import { Time } from '../../model/horz-scale-behavior-time/types'; + +/** + * Enumeration representing the sign of a marker. + */ +export const enum MarkerSign { + /** Represents a negative change (-1) */ + Negative = -1, + /** Represents no change (0) */ + Neutral, + /** Represents a positive change (1) */ + Positive, +} + +/** + * Represents a marker drawn above or below a data point to indicate a price change update. + * @template T The type of the time value, defaults to Time. + */ +export interface SeriesUpDownMarker { + /** + * The point on the horizontal scale. + */ + time: T; + + /** + * The price value for the data point. + */ + value: number; + + /** + * The direction of the price change. + */ + sign: MarkerSign; +} + +/** + * Represents the coordinates and sign of a marker on the chart. + */ +export interface MarkerCoordinates { + x: number; + y: number; + sign: number; +} + +/** + * Defines the supported series types for up down markers primitive plugin. + */ +export type UpDownMarkersSupportedSeriesTypes = 'Line' | 'Area'; diff --git a/src/plugins/up-down-markers-plugin/view.ts b/src/plugins/up-down-markers-plugin/view.ts new file mode 100644 index 0000000000..654cd6f7c6 --- /dev/null +++ b/src/plugins/up-down-markers-plugin/view.ts @@ -0,0 +1,83 @@ +import { ISeriesApi } from '../../api/iseries-api'; +import { ITimeScaleApi } from '../../api/itime-scale-api'; + +import { ensureNotNull } from '../../helpers/assertions'; +import { notNull } from '../../helpers/strict-type-checks'; + +import { IPrimitivePaneView } from '../../model/ipane-primitive'; +import { + AreaSeriesOptions, + LineSeriesOptions, + SeriesOptionsMap, +} from '../../model/series-options'; + +import { UpDownMarkersPluginOptions } from './options'; +import { MarkersPrimitiveRenderer } from './renderer'; +import { + MarkerCoordinates, + SeriesUpDownMarker, + UpDownMarkersSupportedSeriesTypes, +} from './types'; + +function isAreaStyleOptions(opts: SupportedSeriesOptions, seriesType: UpDownMarkersSupportedSeriesTypes): opts is AreaSeriesOptions { + return seriesType === 'Area'; +} + +function getNeutralColor(opts: LineSeriesOptions | AreaSeriesOptions, seriesType: TSeriesType): string { + if (isAreaStyleOptions(opts, seriesType)) { + return opts.lineColor; + } + return opts.color; +} + +type SupportedSeriesOptions = SeriesOptionsMap[UpDownMarkersSupportedSeriesTypes]; + +export class MarkersPrimitivePaneView< + HorzScaleItem, + TSeriesType extends UpDownMarkersSupportedSeriesTypes +> implements IPrimitivePaneView { + private readonly _series: ISeriesApi; + private readonly _timeScale: ITimeScaleApi; + private readonly _options: UpDownMarkersPluginOptions; + private _data: MarkerCoordinates[] = []; + + public constructor( + series: ISeriesApi, + timeScale: ITimeScaleApi, + options: UpDownMarkersPluginOptions + ) { + this._series = series; + this._timeScale = timeScale; + this._options = options; + } + + public update(markers: readonly SeriesUpDownMarker[]): void { + this._data = markers.map((marker: SeriesUpDownMarker) => { + const y = this._series.priceToCoordinate(marker.value); + if (y === null) { + return null; + } + const x = ensureNotNull( + this._timeScale.timeToCoordinate(marker.time) + ); + return { + x, + y, + sign: marker.sign, + }; + }) + .filter(notNull); + } + + public renderer(): MarkersPrimitiveRenderer { + const options = this._series.options(); + const seriesType = this._series.seriesType(); + const neutralColor = getNeutralColor(options, seriesType); + return new MarkersPrimitiveRenderer( + this._data, + neutralColor, + this._options.negativeColor, + this._options.positiveColor + ); + } +} diff --git a/tests/e2e/coverage/test-cases/chart/yield-curve.js b/tests/e2e/coverage/test-cases/chart/yield-curve.js new file mode 100644 index 0000000000..90e0935159 --- /dev/null +++ b/tests/e2e/coverage/test-cases/chart/yield-curve.js @@ -0,0 +1,114 @@ +const options = { + yieldCurve: { + baseResolution: 1, + minimumTimeRange: 10, + startTimeRange: 3, + }, + layout: { + attributionLogo: false, + }, +}; + +const curve1 = [ + { time: 1, value: 5.378 }, + { time: 2, value: 5.372 }, + { time: 3, value: 5.271 }, + { time: 6, value: 5.094 }, + { time: 12, value: 4.739 }, + { time: 24, value: 4.237 }, + { time: 36, value: 4.036 }, + { time: 60, value: 3.887 }, + { time: 84, value: 3.921 }, + { time: 120, value: 4.007 }, + { time: 240, value: 4.366 }, + { time: 360, value: 4.29 }, +]; +const curve2 = [ + { time: 1, value: 5.381 }, + { time: 2, value: 5.393 }, + { time: 3, value: 5.425 }, + { time: 6, value: 5.494 }, + { time: 12, value: 5.377 }, + { time: 24, value: 4.883 }, + { time: 36, value: 4.554 }, + { time: 60, value: 4.241 }, + { time: 84, value: 4.172 }, + { time: 120, value: 4.084 }, + { time: 240, value: 4.365 }, + { time: 360, value: 4.176 }, +]; + +function interactionsToPerform() { + return []; +} + +let chart; + +async function waitNextFrame() { + return new Promise(resolve => { + requestAnimationFrame(resolve); + }); +} + +function beforeInteractions(container) { + chart = LightweightCharts.createYieldCurveChart(container, options); + + const series1 = chart.addAreaSeries({ + lineType: 2, + color: 'red', + pointMarkersVisible: true, + }); + series1.setData(curve1); + const series2 = chart.addLineSeries({ + lineType: 2, + color: 'green', + pointMarkersVisible: true, + lineWidth: 1, + }); + series2.setData(curve2); + + chart.timeScale().fitContent(); + + return new Promise(resolve => { + requestAnimationFrame(async () => { + series1.update( + { + time: 12, + value: 4.8, + }, + true + ); + series2.update({ + time: 420, + value: 4, + }); + await waitNextFrame(); + series1.update({ + time: 360, + }); + series2.update({ + time: 420, + value: 4, + }); + await waitNextFrame(); + series1.update({ + time: 360, + }); + series2.update({ + time: 420, + value: 4, + }); + await waitNextFrame(); + series1.setData(curve1.slice(0, 10)); + await waitNextFrame(); + series2.setData(curve2.slice(0, 9)); + await waitNextFrame(); + resolve(); + }); + }); +} + +function afterInteractions() { + chart.takeScreenshot(); + return Promise.resolve(); +} diff --git a/tests/e2e/coverage/test-cases/plugins/image-watermark.js b/tests/e2e/coverage/test-cases/plugins/image-watermark.js new file mode 100644 index 0000000000..feb80b02a3 --- /dev/null +++ b/tests/e2e/coverage/test-cases/plugins/image-watermark.js @@ -0,0 +1,93 @@ +function simpleData() { + return [ + { time: 1663740000, value: 10 }, + { time: 1663750000, value: 20 }, + { time: 1663760000, value: 30 }, + ]; +} + +function interactionsToPerform() { + return []; +} + +function svgToDataUrl(svgString) { + // Encode the SVG string + const encodedSvg = encodeURIComponent(svgString); + + // Create the data URL + const dataUrl = `data:image/svg+xml;charset=utf-8,${encodedSvg}`; + + return dataUrl; +} + +const svgString = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +const imageDataUrl = svgToDataUrl(svgString); + +let chart; + +function beforeInteractions(container) { + chart = LightweightCharts.createChart(container); + + const mainSeries = chart.addLineSeries(); + + mainSeries.setData(simpleData()); + + const imageWatermark = new LightweightCharts.ImageWatermark(imageDataUrl, { + alpha: 0.5, + padding: 20, + }); + + const pane = chart.panes()[0]; + pane.attachPrimitive(imageWatermark); + + return Promise.resolve(); +} + +function afterInteractions() { + chart.applyOptions({ + height: 300, + width: 300, + }); + + return new Promise(resolve => { + requestAnimationFrame(resolve); + }); +} diff --git a/tests/e2e/coverage/test-cases/plugins/up-down-markers.js b/tests/e2e/coverage/test-cases/plugins/up-down-markers.js new file mode 100644 index 0000000000..6de4359f28 --- /dev/null +++ b/tests/e2e/coverage/test-cases/plugins/up-down-markers.js @@ -0,0 +1,55 @@ +function interactionsToPerform() { + return []; +} + +const curve1 = [ + { time: 1, value: 5.378 }, + { time: 2, value: 5.372 }, + { time: 3, value: 5.271 }, + { time: 6, value: 5.094 }, + { time: 12, value: 4.739 }, + { time: 24, value: 4.237 }, + { time: 36, value: 4.036 }, + { time: 60, value: 3.887 }, + { time: 84, value: 3.921 }, + { time: 120, value: 4.007 }, + { time: 240, value: 4.366 }, + { time: 360, value: 4.29 }, +]; + +let chart; + +function beforeInteractions(container) { + chart = LightweightCharts.createYieldCurveChart(container, { + yieldCurve: { + baseResolution: 1, + minimumTimeRange: 10, + startTimeRange: 3, + }, + layout: { + attributionLogo: false, + }, + }); + + const series1 = chart.addLineSeries({ + lineType: 2, + color: 'black', + pointMarkersVisible: true, + }); + + const primitive = new LightweightCharts.UpDownMarkersPrimitive(); + series1.attachPrimitive(primitive); + primitive.setData(curve1); + + primitive.update({ time: 24, value: 4.3 }, true); // up + primitive.update({ time: 36, value: 4.036 }, true); // neutral + primitive.update({ time: 60, value: 3.8 }, true); // down + + chart.timeScale().fitContent(); + + return Promise.resolve(); +} + +function afterInteractions() { + return Promise.resolve(); +} diff --git a/tests/e2e/coverage/test-cases/series/update-data.js b/tests/e2e/coverage/test-cases/series/update-data.js index 97c2461eda..4fa1b45d32 100644 --- a/tests/e2e/coverage/test-cases/series/update-data.js +++ b/tests/e2e/coverage/test-cases/series/update-data.js @@ -88,5 +88,22 @@ async function afterInteractions() { ...barData[barData.length - 1], time: barData[barData.length - 1].time + 3600, }); + + await awaitNewFrame(); + + lineSeries.update( + { + ...barData[barData.length - 5], + value: 1234, + }, + true // historical update + ); + lineSeries.update( + { + time: barData[barData.length - 6].time, + }, + true // historical update + ); + return Promise.resolve(); } diff --git a/tests/e2e/graphics/test-cases/historical-data-updates.js b/tests/e2e/graphics/test-cases/historical-data-updates.js new file mode 100644 index 0000000000..1828b86ab3 --- /dev/null +++ b/tests/e2e/graphics/test-cases/historical-data-updates.js @@ -0,0 +1,50 @@ +function generateData() { + const res = []; + const time = new Date(Date.UTC(2018, 0, 1, 0, 0, 0, 0)); + for (let i = 0; i < 50; ++i) { + res.push({ + time: time.getTime() / 1000, + value: i, + }); + + time.setUTCDate(time.getUTCDate() + 1); + } + return res; +} + +function runTestCase(container) { + const chart = (window.chart = LightweightCharts.createChart(container, { + layout: { + attributionLogo: true, + }, + })); + + const mainSeries = chart.addLineSeries(); + + const data = generateData(); + const thirdLastPoint = { + ...data[data.length - 4], + }; + mainSeries.setData(data); + + try { + mainSeries.update(thirdLastPoint); + + console.assert( + false, + 'should fail if older update and not setting historicalUpdate to true' + ); + } catch (e) { + // passed + } + try { + mainSeries.update( + { ...thirdLastPoint, value: thirdLastPoint.value - 10 }, + true + ); + } catch (e) { + // would fail on older version of library, + // but graphics difference should be visible + } + chart.timeScale().fitContent(); +} diff --git a/tests/e2e/graphics/test-cases/options-chart.js b/tests/e2e/graphics/test-cases/options-chart.js new file mode 100644 index 0000000000..e2e15a605b --- /dev/null +++ b/tests/e2e/graphics/test-cases/options-chart.js @@ -0,0 +1,20 @@ +function runTestCase(container) { + const chart = (window.chart = LightweightCharts.createOptionsChart( + container, + { layout: { attributionLogo: false }, localization: { precision: 1 } } + )); + + const lineSeries = chart.addLineSeries({ color: 'blue' }); + + const data = []; + for (let i = 0; i < 1000; i++) { + data.push({ + time: i * 0.25, + value: Math.sin(i / 100) + i / 500, + }); + } + + lineSeries.setData(data); + + chart.timeScale().fitContent(); +} diff --git a/tests/e2e/graphics/test-cases/plugins/up-down-markers.js b/tests/e2e/graphics/test-cases/plugins/up-down-markers.js new file mode 100644 index 0000000000..cdd799e95c --- /dev/null +++ b/tests/e2e/graphics/test-cases/plugins/up-down-markers.js @@ -0,0 +1,48 @@ +const options = { + yieldCurve: { + baseResolution: 1, + minimumTimeRange: 10, + startTimeRange: 3, + }, + layout: { + attributionLogo: false, + }, +}; + +const curve1 = [ + { time: 1, value: 5.378 }, + { time: 2, value: 5.372 }, + { time: 3, value: 5.271 }, + { time: 6, value: 5.094 }, + { time: 12, value: 4.739 }, + { time: 24, value: 4.237 }, + { time: 36, value: 4.036 }, + { time: 60, value: 3.887 }, + { time: 84, value: 3.921 }, + { time: 120, value: 4.007 }, + { time: 240, value: 4.366 }, + { time: 360, value: 4.29 }, +]; + +function runTestCase(container) { + const chart = (window.chart = LightweightCharts.createYieldCurveChart( + container, + options + )); + + const series1 = chart.addLineSeries({ + lineType: 2, + color: 'black', + pointMarkersVisible: true, + }); + + const primitive = new LightweightCharts.UpDownMarkersPrimitive(); + series1.attachPrimitive(primitive); + primitive.setData(curve1); + + primitive.update({ time: 24, value: 4.3 }, true); // up + primitive.update({ time: 36, value: 4.036 }, true); // neutral + primitive.update({ time: 60, value: 3.8 }, true); // down + + chart.timeScale().fitContent(); +} diff --git a/tests/e2e/graphics/test-cases/time-scale/ignore-whitespace-indices.js b/tests/e2e/graphics/test-cases/time-scale/ignore-whitespace-indices.js new file mode 100644 index 0000000000..16bb99f786 --- /dev/null +++ b/tests/e2e/graphics/test-cases/time-scale/ignore-whitespace-indices.js @@ -0,0 +1,37 @@ +function generateWhiteSpaceData() { + const res = []; + const time = new Date(Date.UTC(2018, 0, 1, 0, 0, 0, 0)); + for (let i = 0; i < 100; ++i) { + res.push({ + time: time.getTime() / 1000, + value: i, + }); + + time.setUTCDate(time.getUTCDate() + 1); + } + return res; +} + +function runTestCase(container) { + const chart = (window.chart = LightweightCharts.createChart(container, { + layout: { + attributionLogo: true, + }, + timeScale: { + /** + * Expecting that only the grid lines for the three points are drawn. + */ + ignoreWhitespaceIndices: true, + }, + })); + + const mainSeries = chart.addLineSeries(); + + const data = generateWhiteSpaceData(); + data[0].value = 50; + data[49].value = 70; + data[99].value = 50; + + mainSeries.setData(data); + chart.timeScale().fitContent(); +} diff --git a/tests/e2e/graphics/test-cases/yield-curve-chart.js b/tests/e2e/graphics/test-cases/yield-curve-chart.js new file mode 100644 index 0000000000..8a67f554dd --- /dev/null +++ b/tests/e2e/graphics/test-cases/yield-curve-chart.js @@ -0,0 +1,62 @@ +const options = { + yieldCurve: { + baseResolution: 1, + minimumTimeRange: 10, + startTimeRange: 3, + }, + layout: { + attributionLogo: false, + }, +}; + +const curve1 = [ + { time: 1, value: 5.378 }, + { time: 2, value: 5.372 }, + { time: 3, value: 5.271 }, + { time: 6, value: 5.094 }, + { time: 12, value: 4.739 }, + { time: 24, value: 4.237 }, + { time: 36, value: 4.036 }, + { time: 60, value: 3.887 }, + { time: 84, value: 3.921 }, + { time: 120, value: 4.007 }, + { time: 240, value: 4.366 }, + { time: 360, value: 4.29 }, +]; +const curve2 = [ + { time: 1, value: 5.381 }, + { time: 2, value: 5.393 }, + { time: 3, value: 5.425 }, + { time: 6, value: 5.494 }, + { time: 12, value: 5.377 }, + { time: 24, value: 4.883 }, + { time: 36, value: 4.554 }, + { time: 60, value: 4.241 }, + { time: 84, value: 4.172 }, + { time: 120, value: 4.084 }, + { time: 240, value: 4.365 }, + { time: 360, value: 4.176 }, +]; + +function runTestCase(container) { + const chart = (window.chart = LightweightCharts.createYieldCurveChart( + container, + options + )); + + const series1 = chart.addLineSeries({ + lineType: 2, + color: 'red', + pointMarkersVisible: true, + }); + series1.setData(curve1); + const series2 = chart.addLineSeries({ + lineType: 2, + color: 'green', + pointMarkersVisible: true, + lineWidth: 1, + }); + series2.setData(curve2); + + chart.timeScale().fitContent(); +} diff --git a/tests/unittests/data-layer.spec.ts b/tests/unittests/data-layer.spec.ts index d3fbc393a7..0c0ec9e340 100644 --- a/tests/unittests/data-layer.spec.ts +++ b/tests/unittests/data-layer.spec.ts @@ -35,6 +35,10 @@ function dataItemAt(time: Time): BarData