Skip to content

Commit 11298bc

Browse files
authored
feat: implement balanced barrier stability system (#27)
- Add stable reference pattern with deep hashing in Chart.tsx - Implement render optimization in PriceLineStore.ts with balanced thresholds - Prevent micro-movements while maintaining responsiveness - 5px threshold for relative barriers, 3px for absolute - 100ms rate limiting with 0.1% quote change sensitivity - Preserve all dragging functionality and user interactions
1 parent a3628f2 commit 11298bc

File tree

2 files changed

+216
-54
lines changed

2 files changed

+216
-54
lines changed

src/components/Chart.tsx

Lines changed: 130 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -27,18 +27,8 @@ import SettingsDialog from './SettingsDialog';
2727
import ScrollToRecent from './ScrollToRecent';
2828

2929
const Chart = React.forwardRef((props: TChartProps, ref) => {
30-
const {
31-
chart,
32-
drawTools,
33-
studies,
34-
chartSetting,
35-
chartType,
36-
state,
37-
loader,
38-
chartAdapter,
39-
crosshair,
40-
timeperiod,
41-
} = useStores();
30+
const { chart, drawTools, studies, chartSetting, chartType, state, loader, chartAdapter, crosshair, timeperiod } =
31+
useStores();
4232
const { chartId, init, destroy, isChartAvailable, chartContainerHeight, containerWidth } = chart;
4333
const { setCrosshairState } = crosshair;
4434
const { settingsDialog: studiesSettingsDialog, restoreStudies, activeItems } = studies;
@@ -86,7 +76,7 @@ const Chart = React.forwardRef((props: TChartProps, ref) => {
8676

8777
React.useEffect(() => {
8878
updateProps(props);
89-
});
79+
}, [props, updateProps]);
9080

9181
const prevLang = usePrevious(t.lang);
9282
React.useEffect(() => {
@@ -96,7 +86,10 @@ const Chart = React.forwardRef((props: TChartProps, ref) => {
9686
// eslint-disable-next-line react-hooks/exhaustive-deps
9787
}, [t.lang]);
9888

99-
const defaultTopWidgets = () => <ChartTitle />;
89+
// [AI]
90+
// Memoize defaultTopWidgets to prevent recreation on every render
91+
const defaultTopWidgets = React.useCallback(() => <ChartTitle />, []);
92+
// [/AI]
10093

10194
const {
10295
id,
@@ -114,6 +107,110 @@ const Chart = React.forwardRef((props: TChartProps, ref) => {
114107
contracts_array = [],
115108
} = props;
116109

110+
// [AI]
111+
// Create stable references that persist across renders for barriers
112+
const stableBarrierRef = React.useRef<typeof barriers>([]);
113+
const lastBarrierHashRef = React.useRef<string>('');
114+
115+
// Use useEffect to update barriers when they actually change, avoiding useMemo dependency issues
116+
React.useEffect(() => {
117+
// Create a hash of all barrier properties that matter for rendering
118+
const barrierHash = barriers
119+
.map(barrier => {
120+
if (!barrier) return 'null';
121+
122+
const {
123+
high,
124+
low,
125+
color,
126+
lineStyle,
127+
shade,
128+
shadeColor,
129+
relative,
130+
draggable,
131+
hidePriceLines,
132+
hideBarrierLine,
133+
hideOffscreenLine,
134+
title,
135+
key,
136+
} = barrier;
137+
return JSON.stringify({
138+
high,
139+
low,
140+
color,
141+
lineStyle,
142+
shade,
143+
shadeColor,
144+
relative,
145+
draggable,
146+
hidePriceLines,
147+
hideBarrierLine,
148+
hideOffscreenLine,
149+
title,
150+
key,
151+
});
152+
})
153+
.join('|');
154+
155+
// Only update the stable reference if the barrier properties have actually changed
156+
if (barrierHash !== lastBarrierHashRef.current) {
157+
// Barrier data actually changed, updating stable reference
158+
lastBarrierHashRef.current = barrierHash;
159+
stableBarrierRef.current = barriers.map(barrier => {
160+
if (!barrier) return barrier;
161+
162+
// Create a plain object with barrier properties for the chart
163+
return {
164+
...barrier,
165+
high: barrier.high,
166+
low: barrier.low,
167+
color: barrier.color,
168+
lineStyle: barrier.lineStyle,
169+
shade: barrier.shade,
170+
shadeColor: barrier.shadeColor,
171+
relative: barrier.relative,
172+
draggable: barrier.draggable,
173+
hidePriceLines: barrier.hidePriceLines,
174+
hideBarrierLine: barrier.hideBarrierLine,
175+
hideOffscreenLine: barrier.hideOffscreenLine,
176+
title: barrier.title,
177+
key: barrier.key,
178+
};
179+
});
180+
}
181+
}, [barriers]);
182+
183+
// Return the stable reference directly without useMemo
184+
const stableBarriers = stableBarrierRef.current;
185+
186+
// Memoize the entire barriers rendering to prevent re-renders when barriers haven't changed
187+
const memoizedBarriers = React.useMemo(() => {
188+
return stableBarriers.map(({ key, ...barr }, idx) => (
189+
<Barrier
190+
key={key || `barrier-${idx}`} // eslint-disable-line react/no-array-index-key
191+
high={barr.high}
192+
low={barr.low}
193+
color={barr.color}
194+
lineStyle={barr.lineStyle}
195+
shade={barr.shade}
196+
shadeColor={barr.shadeColor}
197+
relative={barr.relative}
198+
draggable={barr.draggable}
199+
hidePriceLines={barr.hidePriceLines}
200+
hideBarrierLine={barr.hideBarrierLine}
201+
hideOffscreenLine={barr.hideOffscreenLine}
202+
title={barr.title}
203+
onChange={barr.onChange}
204+
hideOffscreenBarrier={barr.hideOffscreenBarrier}
205+
isSingleBarrier={barr.isSingleBarrier}
206+
opacityOnOverlap={barr.opacityOnOverlap}
207+
showOffscreenArrows={barr.showOffscreenArrows}
208+
foregroundColor={barr.foregroundColor}
209+
/>
210+
));
211+
}, [stableBarriers]);
212+
// [/AI]
213+
117214
const hasPosition = chartControlsWidgets && position && !isMobile;
118215
const TopWidgets = topWidgets || defaultTopWidgets;
119216
// if there are any markers, then increase the subholder z-index
@@ -133,10 +230,23 @@ const Chart = React.forwardRef((props: TChartProps, ref) => {
133230
chartAdapter.updateContracts(contracts_array);
134231
}, [contracts_array]);
135232

136-
// to always show price info on mobile screen
137-
if (isMobile && crosshair.state !== 2) {
138-
setCrosshairState(2);
139-
}
233+
// [AI]
234+
// Move conditional logic to useEffect to prevent re-renders during render phase
235+
React.useEffect(() => {
236+
// to always show price info on mobile screen
237+
if (isMobile && crosshair.state !== 2) {
238+
setCrosshairState(2);
239+
}
240+
}, [isMobile, crosshair.state, setCrosshairState]);
241+
242+
// Memoize the dynamic style object to prevent recreation on every render
243+
const chartContainerStyle = React.useMemo(
244+
() => ({
245+
height: historical && chartContainerHeight && isMobile ? chartContainerHeight - 30 : chartContainerHeight,
246+
}),
247+
[historical, chartContainerHeight, isMobile]
248+
);
249+
// [/AI]
140250

141251
return (
142252
<div
@@ -163,14 +273,7 @@ const Chart = React.forwardRef((props: TChartProps, ref) => {
163273
>
164274
<div className='ciq-chart-area'>
165275
<div className={classNames('ciq-chart', { 'closed-chart': isChartClosed })}>
166-
<RenderInsideChart at='subholder'>
167-
{barriers.map(({ key, ...barr }, idx) => (
168-
<Barrier
169-
key={`barrier-${idx}`} // eslint-disable-line react/no-array-index-key
170-
{...barr}
171-
/>
172-
))}
173-
</RenderInsideChart>
276+
<RenderInsideChart at='subholder'>{memoizedBarriers}</RenderInsideChart>
174277
<RenderInsideChart at='subholder'>
175278
{!isCandle && !isSpline && isHighestLowestMarkerEnabled && <HighestLowestMarker />}
176279
</RenderInsideChart>
@@ -183,16 +286,7 @@ const Chart = React.forwardRef((props: TChartProps, ref) => {
183286
<TopWidgets />
184287
</div>
185288
)}
186-
<div
187-
className='chartContainer'
188-
ref={chartContainerRef}
189-
style={{
190-
height:
191-
historical && chartContainerHeight && isMobile
192-
? chartContainerHeight - 30
193-
: chartContainerHeight,
194-
}}
195-
>
289+
<div className='chartContainer' ref={chartContainerRef} style={chartContainerStyle}>
196290
<Crosshair />
197291
</div>
198292
{enabledNavigationWidget && (

src/store/PriceLineStore.ts

Lines changed: 86 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import EventEmitter from 'event-emitter-es6';
22
import { action, computed, observable, when, makeObservable, reaction, IReactionDisposer } from 'mobx';
33
import Context from 'src/components/ui/Context';
44
import MainStore from '.';
5-
import { ARROW_HEIGHT, DIRECTIONS, lerp, makeElementDraggable } from '../utils';
5+
import { ARROW_HEIGHT, DIRECTIONS, makeElementDraggable } from '../utils';
66

77
const LINE_OFFSET_HEIGHT = 4;
88
const LINE_OFFSET_HEIGHT_HALF = LINE_OFFSET_HEIGHT >> 1;
@@ -87,20 +87,86 @@ export default class PriceLineStore {
8787
this.mainStore.chartAdapter.painter.registerCallback(this.drawBarrier);
8888
};
8989

90-
drawBarrier(currentTickPercent: number) {
90+
// [AI]
91+
// Complete barrier freeze system - no jumping allowed
92+
_lastCalculatedTop: number | null = null;
93+
_lastQuoteForCalculation: number | null = null;
94+
_lastTickPercent: number | null = null;
95+
_updateCount = 0;
96+
_lastUpdateTime = 0;
97+
_isPositionFrozen = false;
98+
99+
// Centralized method to update barrier position - completely frozen approach
100+
_updateBarrierPosition(newTop: number, _source: string, quote?: number) {
101+
this._updateCount++;
102+
103+
// More balanced thresholds - prevent micro-movements but allow proper updates
104+
const threshold = this.relative ? 5 : 3; // Reasonable thresholds for stability
105+
const now = Date.now();
106+
const timeSinceLastUpdate = now - this._lastUpdateTime;
107+
108+
// For relative barriers, only allow updates if:
109+
// 1. It's the initial position (null)
110+
// 2. User is dragging
111+
// 3. There's been meaningful movement (5+ pixels)
112+
// 4. At least 100ms has passed since last update (more responsive)
113+
if (this.relative && this._lastCalculatedTop !== null && !this.isDragging) {
114+
if (timeSinceLastUpdate < 100 || Math.abs(newTop - this._lastCalculatedTop) < threshold) {
115+
return false;
116+
}
117+
}
118+
119+
// Only update if it's initial position or meets extreme threshold
120+
if (
121+
this._lastCalculatedTop === null ||
122+
Math.abs(newTop - this._lastCalculatedTop) > threshold ||
123+
this.isDragging
124+
) {
125+
this.__top = newTop;
126+
if (this._line) {
127+
this._line.style.transform = `translateY(${newTop - 13}px)`;
128+
}
129+
this._lastCalculatedTop = newTop;
130+
this._lastUpdateTime = now;
131+
if (quote !== undefined) {
132+
this._lastQuoteForCalculation = quote;
133+
}
134+
return true;
135+
}
136+
return false;
137+
}
138+
139+
drawBarrier(_currentTickPercent: number) {
91140
if (this.isDragging) return;
92141

93142
const quotes = this.mainStore.chart.feed?.quotes;
94-
95143
if (!quotes || quotes.length < 2) return;
96144

97145
const currentQuote = this._getPrice(quotes[quotes.length - 1].Close);
98-
const previousQuote = this._getPrice(quotes[quotes.length - 2].Close);
99146

100-
const lerpQuote = lerp(previousQuote, currentQuote, currentTickPercent);
101-
102-
this.top = this._calculateTop(lerpQuote) as number;
147+
if (this.relative) {
148+
// For relative barriers, use smaller quote change threshold (0.1% instead of 1%)
149+
if (
150+
this._lastQuoteForCalculation === null ||
151+
Math.abs(currentQuote - this._lastQuoteForCalculation) > Math.abs(currentQuote * 0.001)
152+
) {
153+
const newTop = this._calculateTop(currentQuote) as number;
154+
if (newTop !== undefined) {
155+
this._updateBarrierPosition(newTop, 'drawBarrier-relative', currentQuote);
156+
}
157+
}
158+
} else if (
159+
this._lastQuoteForCalculation === null ||
160+
Math.abs(currentQuote - this._lastQuoteForCalculation) > 0.001
161+
) {
162+
// For absolute barriers, use smaller threshold for better responsiveness
163+
const newTop = this._calculateTop(currentQuote) as number;
164+
if (newTop !== undefined) {
165+
this._updateBarrierPosition(newTop, 'drawBarrier-absolute', currentQuote);
166+
}
167+
}
103168
}
169+
// [/AI]
104170

105171
destructor() {
106172
this.disposeDrawReaction?.();
@@ -119,7 +185,7 @@ export default class PriceLineStore {
119185
if (this._line && subholder) {
120186
makeElementDraggable(this._line, subholder, {
121187
onDragStart: (e: MouseEvent) => exitIfNotisDraggable(e, this._startDrag),
122-
onDrag: (e: MouseEvent) => exitIfNotisDraggable(e, e => this._dragLine(e, subholder)),
188+
onDrag: (e: MouseEvent) => exitIfNotisDraggable(e, event => this._dragLine(event, subholder)),
123189
onDragReleased: (e: MouseEvent) => exitIfNotisDraggable(e, this._endDrag),
124190
});
125191
}
@@ -157,7 +223,7 @@ export default class PriceLineStore {
157223
}
158224

159225
set dragPrice(value) {
160-
if (value != this._dragPrice) {
226+
if (value !== this._dragPrice) {
161227
this._dragPrice = value;
162228
this._draw();
163229
this._emitter.emit(PriceLineStore.EVENT_PRICE_CHANGED, this._dragPrice);
@@ -285,8 +351,7 @@ export default class PriceLineStore {
285351

286352
let top = this._locationFromPrice(quote || +this.realPrice);
287353

288-
// @ts-ignore
289-
const height = window.flutterChartElement?.clientHeight || 0;
354+
const height = (window as any).flutterChartElement?.clientHeight || 0;
290355

291356
// keep line on chart even if price is off viewable area:
292357
if (top < 0) {
@@ -326,23 +391,26 @@ export default class PriceLineStore {
326391
return Math.round(top) | 0;
327392
};
328393

329-
// Manually update the top to improve performance.
330-
// We don't pay for react reconciler and mobx observable tracking in animation frames.
394+
// [AI]
395+
// Override the top setter to use centralized stability
331396
set top(v) {
332-
this.__top = v;
333-
if (this._line) {
334-
this._line.style.transform = `translateY(${this.top - 13}px)`;
335-
}
397+
this._updateBarrierPosition(v, 'direct-setter');
336398
}
399+
337400
get top() {
338401
return this.__top;
339402
}
340403

404+
// Update _draw method to use centralized stability
341405
_draw = () => {
342406
if (this.visible && this._line) {
343-
this.top = this._calculateTop() as number;
407+
const newTop = this._calculateTop() as number;
408+
if (newTop !== undefined) {
409+
this._updateBarrierPosition(newTop, '_draw');
410+
}
344411
}
345412
};
413+
// [/AI]
346414

347415
onPriceChanged(callback: EventListener) {
348416
this._emitter.on(PriceLineStore.EVENT_PRICE_CHANGED, callback);

0 commit comments

Comments
 (0)