Skip to content

Commit fb59487

Browse files
authored
Merge pull request #1856 from tradingview/feat/boundaries-price-marks
feat: boundaries price marks
2 parents 266fc66 + d98536c commit fb59487

14 files changed

+425
-16
lines changed

src/api/iprice-scale-api.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { DeepPartial } from '../helpers/strict-type-checks';
22

33
import { PriceScaleOptions } from '../model/price-scale';
4+
import { IRange } from '../model/time-data';
45

56
/** Interface to control chart's price scale */
67
export interface IPriceScaleApi {
@@ -22,4 +23,25 @@ export interface IPriceScaleApi {
2223
* Returns a width of the price scale if it's visible or 0 if invisible.
2324
*/
2425
width(): number;
26+
27+
/**
28+
* Sets the visible range of the price scale.
29+
*
30+
* @param range - The visible range to set, with `from` and `to` properties.
31+
*/
32+
setVisibleRange(range: IRange<number>): void;
33+
34+
/**
35+
* Returns the visible range of the price scale.
36+
*
37+
* @returns The visible range of the price scale, or null if the range is not set.
38+
*/
39+
getVisibleRange(): IRange<number> | null;
40+
41+
/**
42+
* Sets the auto scale mode of the price scale.
43+
*
44+
* @param on - If true, enables auto scaling; if false, disables it.
45+
*/
46+
setAutoScale(on: boolean): void;
2547
}

src/api/options/price-scale-options-defaults.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,5 @@ export const priceScaleOptionsDefaults: PriceScaleOptions = {
1515
top: 0.2,
1616
},
1717
minimumWidth: 0,
18+
ensureEdgeTickMarksVisible: false,
1819
};

src/api/price-scale-api.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import { ensureNotNull } from '../helpers/assertions';
44
import { DeepPartial } from '../helpers/strict-type-checks';
55

66
import { isDefaultPriceScale } from '../model/default-price-scale';
7+
import { PriceRangeImpl } from '../model/price-range-impl';
78
import { PriceScale, PriceScaleOptions } from '../model/price-scale';
9+
import { IRange } from '../model/time-data';
810

911
import { IPriceScaleApi } from './iprice-scale-api';
1012

@@ -35,6 +37,23 @@ export class PriceScaleApi implements IPriceScaleApi {
3537
return this._chartWidget.getPriceAxisWidth(this._priceScaleId);
3638
}
3739

40+
public setVisibleRange(range: IRange<number>): void {
41+
this.setAutoScale(false);
42+
this._priceScale().setCustomPriceRange(new PriceRangeImpl(range.from, range.to));
43+
}
44+
45+
public getVisibleRange(): IRange<number> | null {
46+
const range = this._priceScale().priceRange();
47+
return range === null ? null : {
48+
from: range.minValue(),
49+
to: range.maxValue(),
50+
};
51+
}
52+
53+
public setAutoScale(on: boolean): void {
54+
this.applyOptions({ autoScale: on });
55+
}
56+
3857
private _priceScale(): PriceScale {
3958
return ensureNotNull(this._chartWidget.model().findPriceScale(this._priceScaleId, this._paneIndex)).priceScale;
4059
}

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export const customSeriesDefaultOptions: CustomSeriesOptions = {
1818
...seriesOptionsDefaults,
1919
...customStyleDefaults,
2020
};
21-
export { ICustomSeriesPaneView, ICustomSeriesPaneRenderer, CustomBarItemData, CustomData } from './model/icustom-series';
21+
export type { ICustomSeriesPaneView, ICustomSeriesPaneRenderer, CustomBarItemData, CustomData } from './model/icustom-series';
2222

2323
export { createChart, createChartEx, defaultHorzScaleBehavior } from './api/create-chart';
2424
export { createYieldCurveChart } from './api/create-yield-curve-chart';

src/model/price-scale.ts

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,15 @@ export interface PriceScaleOptions {
192192
* @defaultValue 0
193193
*/
194194
minimumWidth: number;
195+
196+
/**
197+
* Ensures that tick marks are always visible at the very top and bottom of the price scale,
198+
* regardless of the data range. When enabled, a tick mark will be drawn at both edges of the scale,
199+
* providing clear boundary indicators.
200+
*
201+
* @defaultValue false
202+
*/
203+
ensureEdgeTickMarksVisible: boolean;
195204
}
196205

197206
interface RangeCache {
@@ -224,6 +233,8 @@ export class PriceScale {
224233
private _priceRangeSnapshot: PriceRangeImpl | null = null;
225234
private _invalidatedForRange: RangeCache = { isValid: false, visibleBars: null };
226235

236+
private _isCustomPriceRange: boolean = false;
237+
227238
private _marginAbove: number = 0;
228239
private _marginBelow: number = 0;
229240

@@ -251,7 +262,12 @@ export class PriceScale {
251262
this._layoutOptions = layoutOptions;
252263
this._localizationOptions = localizationOptions;
253264
this._colorParser = colorParser;
254-
this._markBuilder = new PriceTickMarkBuilder(this, 100, this._coordinateToLogical.bind(this), this._logicalToCoordinate.bind(this));
265+
this._markBuilder = new PriceTickMarkBuilder(
266+
this,
267+
100,
268+
this._coordinateToLogical.bind(this),
269+
this._logicalToCoordinate.bind(this)
270+
);
255271
}
256272

257273
public id(): string {
@@ -295,6 +311,10 @@ export class PriceScale {
295311
return this._options.autoScale;
296312
}
297313

314+
public isCustomPriceRange(): boolean {
315+
return this._isCustomPriceRange;
316+
}
317+
298318
public isLog(): boolean {
299319
return this._options.mode === PriceScaleMode.Logarithmic;
300320
}
@@ -307,6 +327,10 @@ export class PriceScale {
307327
return this._options.mode === PriceScaleMode.IndexedTo100;
308328
}
309329

330+
public getLogFormula(): LogFormula {
331+
return this._logFormula;
332+
}
333+
310334
public mode(): PriceScaleState {
311335
return {
312336
autoScale: this._options.autoScale,
@@ -422,6 +446,11 @@ export class PriceScale {
422446
this._priceRange = newPriceRange;
423447
}
424448

449+
public setCustomPriceRange(newPriceRange: PriceRangeImpl | null): void {
450+
this.setPriceRange(newPriceRange);
451+
this._toggleCustomPriceRange(newPriceRange !== null);
452+
}
453+
425454
public isEmpty(): boolean {
426455
this._makeSureItIsValid();
427456
return this._height === 0 || !this._priceRange || this._priceRange.isEmpty();
@@ -792,6 +821,14 @@ export class PriceScale {
792821
this._dataSources.forEach((s: IPriceDataSource) => s.updateAllViews());
793822
}
794823

824+
public hasVisibleEdgeMarks(): boolean {
825+
return this._options.ensureEdgeTickMarksVisible && this.isAutoScale();
826+
}
827+
828+
public getEdgeMarksPadding(): number {
829+
return this.fontSize() / 2;
830+
}
831+
795832
public updateFormatter(): void {
796833
this._marksCache = null;
797834

@@ -842,6 +879,10 @@ export class PriceScale {
842879
return this._colorParser;
843880
}
844881

882+
private _toggleCustomPriceRange(v: boolean): void {
883+
this._isCustomPriceRange = v;
884+
}
885+
845886
private _topMarginPx(): number {
846887
return this.isInverted()
847888
? this._options.scaleMargins.bottom * this.height() + this._marginBelow
@@ -899,6 +940,10 @@ export class PriceScale {
899940

900941
// eslint-disable-next-line complexity
901942
private _recalculatePriceRangeImpl(): void {
943+
if (this.isCustomPriceRange() && !this.isAutoScale()) {
944+
return;
945+
}
946+
902947
const visibleBars = this._invalidatedForRange.visibleBars;
903948
if (visibleBars === null) {
904949
return;
@@ -952,6 +997,11 @@ export class PriceScale {
952997
}
953998
}
954999

1000+
if (this.hasVisibleEdgeMarks()) {
1001+
marginAbove = Math.max(marginAbove, this.getEdgeMarksPadding());
1002+
marginBelow = Math.max(marginBelow, this.getEdgeMarksPadding());
1003+
}
1004+
9551005
if (marginAbove !== this._marginAbove || marginBelow !== this._marginBelow) {
9561006
this._marginAbove = marginAbove;
9571007
this._marginBelow = marginBelow;
@@ -1001,8 +1051,6 @@ export class PriceScale {
10011051
this._logFormula = logFormulaForPriceRange(null);
10021052
}
10031053
}
1004-
1005-
this._invalidatedForRange.isValid = true;
10061054
}
10071055

10081056
private _getCoordinateTransformer(): PriceTransformer | null {

src/model/price-tick-mark-builder.ts

Lines changed: 112 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import { ensure } from '../helpers/assertions';
12
import { min } from '../helpers/mathex';
23

34
import { Coordinate } from './coordinate';
45
import { PriceMark, PriceScale } from './price-scale';
6+
import { convertPriceRangeFromLog } from './price-scale-conversions';
57
import { PriceTickSpanCalculator } from './price-tick-span-calculator';
68

79
export type CoordinateToLogicalConverter = (x: number, firstValue: number) => number;
@@ -74,12 +76,51 @@ export class PriceTickMarkBuilder {
7476

7577
const high = Math.max(bottom, top);
7678
const low = Math.min(bottom, top);
79+
7780
if (high === low) {
7881
this._marks = [];
7982
return;
8083
}
8184

82-
let span = this.tickSpan(high, low);
85+
const span = this.tickSpan(high, low);
86+
this._updateMarks(
87+
firstValue,
88+
span,
89+
high,
90+
low,
91+
minCoord,
92+
maxCoord
93+
);
94+
95+
if (priceScale.hasVisibleEdgeMarks() && this._shouldApplyEdgeMarks(span, low, high)) {
96+
const padding = this._priceScale.getEdgeMarksPadding();
97+
this._applyEdgeMarks(
98+
firstValue,
99+
span,
100+
minCoord,
101+
maxCoord,
102+
padding,
103+
padding * 2
104+
);
105+
}
106+
}
107+
108+
public marks(): PriceMark[] {
109+
return this._marks;
110+
}
111+
112+
private _fontHeight(): number {
113+
return this._priceScale.fontSize();
114+
}
115+
116+
private _tickMarkHeight(): number {
117+
return Math.ceil(this._fontHeight() * TICK_DENSITY);
118+
}
119+
120+
private _updateMarks(firstValue: number, span: number, high: number, low: number, minCoord: number, maxCoord: number): void {
121+
const marks = this._marks;
122+
const priceScale = this._priceScale;
123+
83124
let mod = high % span;
84125
mod += mod < 0 ? span : 0;
85126

@@ -102,11 +143,11 @@ export class PriceTickMarkBuilder {
102143
continue;
103144
}
104145

105-
if (targetIndex < this._marks.length) {
106-
this._marks[targetIndex].coord = coord as Coordinate;
107-
this._marks[targetIndex].label = priceScale.formatLogical(logical);
146+
if (targetIndex < marks.length) {
147+
marks[targetIndex].coord = coord as Coordinate;
148+
marks[targetIndex].label = priceScale.formatLogical(logical);
108149
} else {
109-
this._marks.push({
150+
marks.push({
110151
coord: coord as Coordinate,
111152
label: priceScale.formatLogical(logical),
112153
});
@@ -120,18 +161,77 @@ export class PriceTickMarkBuilder {
120161
span = this.tickSpan(logical * sign, low);
121162
}
122163
}
123-
this._marks.length = targetIndex;
164+
165+
marks.length = targetIndex;
124166
}
125167

126-
public marks(): PriceMark[] {
127-
return this._marks;
168+
private _applyEdgeMarks(
169+
firstValue: number,
170+
span: number,
171+
minCoord: number,
172+
maxCoord: number,
173+
minPadding: number,
174+
maxPadding: number
175+
): void {
176+
const marks = this._marks;
177+
// top boundary
178+
const topMark = this._computeBoundaryPriceMark(
179+
firstValue,
180+
minCoord,
181+
minPadding,
182+
maxPadding
183+
);
184+
185+
// bottom boundary
186+
const bottomMark = this._computeBoundaryPriceMark(
187+
firstValue,
188+
maxCoord,
189+
-maxPadding,
190+
-minPadding
191+
);
192+
193+
const spanPx = this._logicalToCoordinateFunc(0, firstValue, true)
194+
- this._logicalToCoordinateFunc(span, firstValue, true);
195+
196+
if (marks.length > 0 && marks[0].coord - topMark.coord < spanPx / 2) {
197+
marks.shift();
198+
}
199+
200+
if (marks.length > 0 && bottomMark.coord - marks[marks.length - 1].coord < spanPx / 2) {
201+
marks.pop();
202+
}
203+
204+
marks.unshift(topMark);
205+
marks.push(bottomMark);
128206
}
129207

130-
private _fontHeight(): number {
131-
return this._priceScale.fontSize();
208+
private _computeBoundaryPriceMark(
209+
firstValue: number,
210+
coord: number,
211+
minPadding: number,
212+
maxPadding: number
213+
): PriceMark {
214+
const avgPadding = (minPadding + maxPadding) / 2;
215+
const value1 = this._coordinateToLogicalFunc(coord + minPadding, firstValue);
216+
const value2 = this._coordinateToLogicalFunc(coord + maxPadding, firstValue);
217+
const minValue = Math.min(value1, value2);
218+
const maxValue = Math.max(value1, value2);
219+
const valueSpan = Math.max(0.1, this.tickSpan(maxValue, minValue));
220+
221+
const value = this._coordinateToLogicalFunc(coord + avgPadding, firstValue);
222+
const roundedValue = value - (value % valueSpan);
223+
const roundedCoord = this._logicalToCoordinateFunc(roundedValue, firstValue, true);
224+
225+
return { label: this._priceScale.formatLogical(roundedValue), coord: roundedCoord as Coordinate };
132226
}
133227

134-
private _tickMarkHeight(): number {
135-
return Math.ceil(this._fontHeight() * TICK_DENSITY);
228+
private _shouldApplyEdgeMarks(span: number, low: number, high: number): boolean {
229+
let range = ensure(this._priceScale.priceRange());
230+
231+
if (this._priceScale.isLog()) {
232+
range = convertPriceRangeFromLog(range, this._priceScale.getLogFormula());
233+
}
234+
235+
return (range.minValue() - low < span) && (high - range.maxValue() < span);
136236
}
137237
}

0 commit comments

Comments
 (0)