From ae0838976a94c64c73c38b3cf5d10caa6b34952a Mon Sep 17 00:00:00 2001 From: Leo Date: Fri, 3 Apr 2026 22:32:39 -0300 Subject: [PATCH] feat(series): add connectGaps option to line, area and baseline series fixes #699 This change introduces a new option connectGaps for continuous series types (Line, Area, Baseline). When set to alse, the renderer will break the line/area at gaps in the data (detected by non-contiguous time indices). It defaults to rue to maintain backward compatibility. --- src/model/series-options.ts | 21 ++++++ src/model/series/area-pane-view.ts | 2 + src/model/series/area-series.ts | 1 + src/model/series/baseline-pane-view.ts | 2 + src/model/series/baseline-series.ts | 1 + src/model/series/line-pane-view.ts | 1 + src/model/series/line-series.ts | 1 + src/renderers/area-renderer-base.ts | 6 +- src/renderers/line-renderer-base.ts | 6 +- src/renderers/walk-line.ts | 22 ++++-- .../test-cases/series/connect-gaps.js | 69 +++++++++++++++++++ 11 files changed, 122 insertions(+), 10 deletions(-) create mode 100644 tests/e2e/graphics/test-cases/series/connect-gaps.js diff --git a/src/model/series-options.ts b/src/model/series-options.ts index a6515fcc3a3..5dc5caedf62 100644 --- a/src/model/series-options.ts +++ b/src/model/series-options.ts @@ -231,6 +231,13 @@ export interface LineStyleOptions { * @defaultValue {@link LastPriceAnimationMode.Disabled} */ lastPriceAnimation: LastPriceAnimationMode; + + /** + * Connect gaps in data. + * + * @defaultValue `true` + */ + connectGaps?: boolean; } /** @@ -351,6 +358,13 @@ export interface AreaStyleOptions { * @defaultValue {@link LastPriceAnimationMode.Disabled} */ lastPriceAnimation: LastPriceAnimationMode; + + /** + * Connect gaps in data. + * + * @defaultValue `true` + */ + connectGaps?: boolean; } /** @@ -504,6 +518,13 @@ export interface BaselineStyleOptions { * @defaultValue {@link LastPriceAnimationMode.Disabled} */ lastPriceAnimation: LastPriceAnimationMode; + + /** + * Connect gaps in data. + * + * @defaultValue `true` + */ + connectGaps?: boolean; } /** diff --git a/src/model/series/area-pane-view.ts b/src/model/series/area-pane-view.ts index edeacd94839..81f71a9a916 100644 --- a/src/model/series/area-pane-view.ts +++ b/src/model/series/area-pane-view.ts @@ -53,6 +53,7 @@ export class SeriesAreaPaneView extends LinePaneViewBase<'Area', AreaFillItem & invertFilledArea: options.invertFilledArea, visibleRange: this._itemsVisibleRange, barWidth: this._model.timeScale().barSpacing(), + connectGaps: options.connectGaps as boolean, }); this._lineRenderer.setData({ @@ -63,6 +64,7 @@ export class SeriesAreaPaneView extends LinePaneViewBase<'Area', AreaFillItem & visibleRange: this._itemsVisibleRange, barWidth: this._model.timeScale().barSpacing(), pointMarkersRadius: options.pointMarkersVisible ? (options.pointMarkersRadius || options.lineWidth / 2 + 2) : undefined, + connectGaps: options.connectGaps as boolean, }); } } diff --git a/src/model/series/area-series.ts b/src/model/series/area-series.ts index f6dd72005ee..3680c293182 100644 --- a/src/model/series/area-series.ts +++ b/src/model/series/area-series.ts @@ -24,6 +24,7 @@ export const areaStyleDefaults: AreaStyleOptions = { crosshairMarkerBackgroundColor: '', lastPriceAnimation: LastPriceAnimationMode.Disabled, pointMarkersVisible: false, + connectGaps: true, }; const createPaneView = (series: ISeries<'Area'>, model: IChartModelBase): IUpdatablePaneView => new SeriesAreaPaneView(series, model); export const createSeries = (): SeriesDefinition<'Area'> => { diff --git a/src/model/series/baseline-pane-view.ts b/src/model/series/baseline-pane-view.ts index 4432e26969f..e29c3f1a3ca 100644 --- a/src/model/series/baseline-pane-view.ts +++ b/src/model/series/baseline-pane-view.ts @@ -68,6 +68,7 @@ export class SeriesBaselinePaneView extends LinePaneViewBase<'Baseline', Baselin invertFilledArea: false, visibleRange: this._itemsVisibleRange, barWidth, + connectGaps: options.connectGaps as boolean, }); this._baselineLineRenderer.setData({ @@ -81,6 +82,7 @@ export class SeriesBaselinePaneView extends LinePaneViewBase<'Baseline', Baselin bottomCoordinate, visibleRange: this._itemsVisibleRange, barWidth, + connectGaps: options.connectGaps as boolean, }); } } diff --git a/src/model/series/baseline-series.ts b/src/model/series/baseline-series.ts index 1b0dc05e142..6024dd409d1 100644 --- a/src/model/series/baseline-series.ts +++ b/src/model/series/baseline-series.ts @@ -34,6 +34,7 @@ export const baselineStyleDefaults: BaselineStyleOptions = { lastPriceAnimation: LastPriceAnimationMode.Disabled, pointMarkersVisible: false, + connectGaps: true, }; const createPaneView = (series: ISeries<'Baseline'>, model: IChartModelBase): IUpdatablePaneView => new SeriesBaselinePaneView(series, model); diff --git a/src/model/series/line-pane-view.ts b/src/model/series/line-pane-view.ts index dc836f94781..b316322b487 100644 --- a/src/model/series/line-pane-view.ts +++ b/src/model/series/line-pane-view.ts @@ -25,6 +25,7 @@ export class SeriesLinePaneView extends LinePaneViewBase<'Line', LineStrokeItem, pointMarkersRadius: options.pointMarkersVisible ? (options.pointMarkersRadius || options.lineWidth / 2 + 2) : undefined, visibleRange: this._itemsVisibleRange, barWidth: this._model.timeScale().barSpacing(), + connectGaps: options.connectGaps as boolean, }; this._renderer.setData(data); diff --git a/src/model/series/line-series.ts b/src/model/series/line-series.ts index b9fe61a11de..44770932699 100644 --- a/src/model/series/line-series.ts +++ b/src/model/series/line-series.ts @@ -20,6 +20,7 @@ export const lineStyleDefaults: LineStyleOptions = { crosshairMarkerBackgroundColor: '', lastPriceAnimation: LastPriceAnimationMode.Disabled, pointMarkersVisible: false, + connectGaps: true, }; const createPaneView = (series: ISeries<'Line'>, model: IChartModelBase): IUpdatablePaneView => new SeriesLinePaneView(series, model); diff --git a/src/renderers/area-renderer-base.ts b/src/renderers/area-renderer-base.ts index 149a8156f40..ac6bafc2b4c 100644 --- a/src/renderers/area-renderer-base.ts +++ b/src/renderers/area-renderer-base.ts @@ -21,6 +21,8 @@ export interface PaneRendererAreaDataBase( +export function walkLine( renderingScope: BitmapCoordinatesRenderingScope, items: readonly TItem[], lineType: LineType, @@ -20,7 +20,8 @@ export function walkLine TStyle, finishStyledArea: (renderingScope: BitmapCoordinatesRenderingScope, style: TStyle, areaFirstItem: LinePoint, newAreaFirstItem: LinePoint) => void, - dashPatternLength: number = 0 + dashPatternLength: number = 0, + connectGaps: boolean = true ): void { if (items.length === 0 || visibleRange.from >= items.length || visibleRange.to <= 0) { return; @@ -74,11 +75,22 @@ export function walkLine 1; + + if (isGap) { + finishStyledArea(renderingScope, currentStyle, currentStyleFirstItem, prevItem); + ctx.beginPath(); + currentStyle = itemStyle; + currentStyleFirstItem = currentItem; + ctx.moveTo(currentX, currentY); + continue; + } + switch (lineType) { case LineType.Simple: { ctx.lineTo(currentX, currentY); if (shouldTrackDashOffset) { - const prevItem = items[i - 1]; const prevX = prevItem.x * horizontalPixelRatio; const prevY = prevItem.y * verticalPixelRatio; accumulatedDistance += distanceByCoordinates(prevX, prevY, currentX, currentY); @@ -86,7 +98,6 @@ export function walkLine 10 && i < 20) { + res.push({ time: currentTime }); + continue; + } + + res.push({ + time: currentTime, + value: 10 + Math.sin(i / 5) * 5, + }); + } + return res; +} + +function runTestCase(container) { + const chart = window.chart = LightweightCharts.createChart(container, { + layout: { + attributionLogo: false, + }, + timeScale: { + barSpacing: 20, + }, + }); + + const data = generateData(); + + // Line Series with connectGaps: false + const lineSeries = chart.addSeries(LightweightCharts.LineSeries, { + color: '#2196F3', + lineWidth: 2, + connectGaps: false, + title: 'Line (No Gaps)', + }); + lineSeries.setData(data); + + // Area Series with connectGaps: false (offset for visibility) + const areaSeries = chart.addSeries(LightweightCharts.AreaSeries, { + topColor: 'rgba(33, 150, 243, 0.4)', + bottomColor: 'rgba(33, 150, 243, 0.1)', + lineColor: '#2196F3', + connectGaps: false, + title: 'Area (No Gaps)', + }); + areaSeries.setData(data.map(d => d.value !== undefined ? { ...d, value: d.value + 10 } : d)); + + // Baseline Series with connectGaps: false + const baselineSeries = chart.addSeries(LightweightCharts.BaselineSeries, { + baseValue: { type: 'price', price: 30 }, + topFillColor1: 'rgba(38, 166, 154, 0.28)', + topFillColor2: 'rgba(38, 166, 154, 0.05)', + topLineColor: 'rgba(38, 166, 154, 1)', + bottomFillColor1: 'rgba(239, 83, 80, 0.05)', + bottomFillColor2: 'rgba(239, 83, 80, 0.28)', + bottomLineColor: 'rgba(239, 83, 80, 1)', + connectGaps: false, + title: 'Baseline (No Gaps)', + }); + baselineSeries.setData(data.map(d => d.value !== undefined ? { ...d, value: d.value + 20 } : d)); + + chart.timeScale().fitContent(); +}