@qfo/qfchart 0.7.3 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/QFChart.ts CHANGED
@@ -10,6 +10,7 @@ import { DrawingEditor } from './components/DrawingEditor';
10
10
  import { EventBus } from './utils/EventBus';
11
11
  import { AxisUtils } from './utils/AxisUtils';
12
12
  import { TableOverlayRenderer } from './components/TableOverlayRenderer';
13
+ import { TableCanvasRenderer } from './components/TableCanvasRenderer';
13
14
 
14
15
  export class QFChart implements ChartContext {
15
16
  private chart: echarts.ECharts;
@@ -54,9 +55,11 @@ export class QFChart implements ChartContext {
54
55
  const pGrid = this.chart.convertFromPixel({ gridIndex: i }, [point.x, point.y]);
55
56
 
56
57
  if (pGrid) {
57
- // Store padded coordinates directly (don't subtract offset)
58
- // This ensures all coordinates are positive and within the valid padded range
59
- return { timeIndex: Math.round(pGrid[0]), value: pGrid[1], paneIndex: i };
58
+ // Store in real data indices (subtract padding offset).
59
+ // This makes drawing coordinates independent of lazy padding
60
+ // expansion when _resizePadding() changes dataIndexOffset,
61
+ // stored coordinates stay valid without manual updating.
62
+ return { timeIndex: Math.round(pGrid[0]) - this.dataIndexOffset, value: pGrid[1], paneIndex: i };
60
63
  }
61
64
  }
62
65
  }
@@ -64,8 +67,8 @@ export class QFChart implements ChartContext {
64
67
  },
65
68
  dataToPixel: (point: { timeIndex: number; value: number; paneIndex?: number }) => {
66
69
  const paneIdx = point.paneIndex || 0;
67
- // Coordinates are already in padded space, so use directly
68
- const p = this.chart.convertToPixel({ gridIndex: paneIdx }, [point.timeIndex, point.value]);
70
+ // Convert real data index back to padded space for ECharts
71
+ const p = this.chart.convertToPixel({ gridIndex: paneIdx }, [point.timeIndex + this.dataIndexOffset, point.value]);
69
72
  if (p) {
70
73
  return { x: p[0], y: p[1] };
71
74
  }
@@ -79,6 +82,12 @@ export class QFChart implements ChartContext {
79
82
  private readonly defaultPadding = 0.0;
80
83
  private padding: number;
81
84
  private dataIndexOffset: number = 0; // Offset for phantom padding data
85
+ private _paddingPoints: number = 0; // Current symmetric padding (empty bars per side)
86
+ private readonly LAZY_MIN_PADDING = 5; // Always have a tiny buffer so edge scroll triggers
87
+ private readonly LAZY_MAX_PADDING = 500; // Hard cap per side
88
+ private readonly LAZY_CHUNK_SIZE = 50; // Bars added per expansion
89
+ private readonly LAZY_EDGE_THRESHOLD = 10; // Bars from edge to trigger
90
+ private _expandScheduled: boolean = false; // Debounce flag
82
91
 
83
92
  // DOM Elements for Layout
84
93
  private rootContainer: HTMLElement;
@@ -89,6 +98,9 @@ export class QFChart implements ChartContext {
89
98
  private chartContainer: HTMLElement;
90
99
  private overlayContainer: HTMLElement;
91
100
  private _lastTables: any[] = [];
101
+ private _tableGraphicIds: string[] = []; // Track canvas table graphic IDs for cleanup
102
+ private _baseGraphics: any[] = []; // Non-table graphic elements (title, watermark, pane labels)
103
+ private _labelTooltipEl: HTMLElement | null = null; // Floating tooltip for label.set_tooltip()
92
104
 
93
105
  // Pane drag-resize state
94
106
  private _lastLayout: (LayoutResult & { overlayYAxisMap: Map<string, number>; separatePaneYAxisOffset: number }) | null = null;
@@ -195,7 +207,8 @@ export class QFChart implements ChartContext {
195
207
  // Overlay container for table rendering (positioned above ECharts canvas)
196
208
  this.chartContainer.style.position = 'relative';
197
209
  this.overlayContainer = document.createElement('div');
198
- this.overlayContainer.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:100;overflow:hidden;';
210
+ this.overlayContainer.style.cssText =
211
+ 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:100;overflow:hidden;';
199
212
  this.chartContainer.appendChild(this.overlayContainer);
200
213
 
201
214
  this.pluginManager = new PluginManager(this, this.toolbarContainer);
@@ -209,22 +222,28 @@ export class QFChart implements ChartContext {
209
222
  const triggerOn = this.options.databox?.triggerOn;
210
223
  const position = this.options.databox?.position;
211
224
  if (triggerOn === 'click' && position === 'floating') {
212
- // Hide tooltip by dispatching a hideTooltip action
213
- this.chart.dispatchAction({
214
- type: 'hideTip',
215
- });
225
+ this.chart.dispatchAction({ type: 'hideTip' });
216
226
  }
227
+
228
+ // Lazy padding: check if user scrolled near an edge
229
+ this._checkEdgeAndExpand();
217
230
  });
218
231
  // @ts-ignore - ECharts event handler type mismatch
219
232
  this.chart.on('finished', (params: any) => this.events.emit('chart:updated', params)); // General chart update
220
233
  // @ts-ignore - ECharts ZRender event handler type mismatch
221
- this.chart.getZr().on('mousedown', (params: any) => { if (!this._paneDragState) this.events.emit('mouse:down', params); });
234
+ this.chart.getZr().on('mousedown', (params: any) => {
235
+ if (!this._paneDragState) this.events.emit('mouse:down', params);
236
+ });
222
237
  // @ts-ignore - ECharts ZRender event handler type mismatch
223
- this.chart.getZr().on('mousemove', (params: any) => { if (!this._paneDragState) this.events.emit('mouse:move', params); });
238
+ this.chart.getZr().on('mousemove', (params: any) => {
239
+ if (!this._paneDragState) this.events.emit('mouse:move', params);
240
+ });
224
241
  // @ts-ignore - ECharts ZRender event handler type mismatch
225
242
  this.chart.getZr().on('mouseup', (params: any) => this.events.emit('mouse:up', params));
226
243
  // @ts-ignore - ECharts ZRender event handler type mismatch
227
- this.chart.getZr().on('click', (params: any) => { if (!this._paneDragState) this.events.emit('mouse:click', params); });
244
+ this.chart.getZr().on('click', (params: any) => {
245
+ if (!this._paneDragState) this.events.emit('mouse:click', params);
246
+ });
228
247
 
229
248
  const zr = this.chart.getZr();
230
249
  const originalSetCursorStyle = zr.setCursorStyle;
@@ -275,9 +294,9 @@ export class QFChart implements ChartContext {
275
294
 
276
295
  // ── Pane border drag-resize ────────────────────────────────
277
296
  private bindPaneResizeEvents(): void {
278
- const MIN_MAIN = 10; // minimum main pane height %
297
+ const MIN_MAIN = 10; // minimum main pane height %
279
298
  const MIN_INDICATOR = 5; // minimum indicator pane height %
280
- const HIT_ZONE = 6; // hit-zone in pixels (±3px from boundary center)
299
+ const HIT_ZONE = 6; // hit-zone in pixels (±3px from boundary center)
281
300
 
282
301
  const zr = this.chart.getZr();
283
302
 
@@ -598,6 +617,47 @@ export class QFChart implements ChartContext {
598
617
  }
599
618
  }
600
619
  });
620
+
621
+ // --- Label Tooltip ---
622
+ // Create floating tooltip overlay for Pine Script label.set_tooltip()
623
+ this._labelTooltipEl = document.createElement('div');
624
+ this._labelTooltipEl.style.cssText =
625
+ 'position:absolute;display:none;pointer-events:none;z-index:200;' +
626
+ 'background:rgba(30,41,59,0.95);color:#fff;border:1px solid #475569;' +
627
+ 'border-radius:4px;padding:6px 10px;font-size:12px;line-height:1.5;' +
628
+ 'white-space:pre-wrap;max-width:350px;box-shadow:0 2px 8px rgba(0,0,0,0.3);' +
629
+ 'font-family:' +
630
+ (this.options.fontFamily || 'sans-serif') +
631
+ ';';
632
+ this.chartContainer.appendChild(this._labelTooltipEl);
633
+
634
+ // Show tooltip on scatter item hover (labels with tooltip text)
635
+ this.chart.on('mouseover', { seriesType: 'scatter' }, (params: any) => {
636
+ const tooltipText = params.data?._tooltipText;
637
+ if (!tooltipText || !this._labelTooltipEl) return;
638
+
639
+ this._labelTooltipEl.textContent = tooltipText;
640
+ this._labelTooltipEl.style.display = 'block';
641
+
642
+ // Position below the scatter point
643
+ const chartRect = this.chartContainer.getBoundingClientRect();
644
+ const event = params.event?.event;
645
+ if (event) {
646
+ const x = event.clientX - chartRect.left;
647
+ const y = event.clientY - chartRect.top;
648
+ // Show below and slightly left of cursor
649
+ const tipWidth = this._labelTooltipEl.offsetWidth;
650
+ const left = Math.min(x - tipWidth / 2, chartRect.width - tipWidth - 8);
651
+ this._labelTooltipEl.style.left = Math.max(4, left) + 'px';
652
+ this._labelTooltipEl.style.top = y + 18 + 'px';
653
+ }
654
+ });
655
+
656
+ this.chart.on('mouseout', { seriesType: 'scatter' }, () => {
657
+ if (this._labelTooltipEl) {
658
+ this._labelTooltipEl.style.display = 'none';
659
+ }
660
+ });
601
661
  }
602
662
 
603
663
  // --- Plugin System Integration ---
@@ -842,19 +902,20 @@ export class QFChart implements ChartContext {
842
902
  paddingPoints,
843
903
  paddedOHLCVForShapes, // Pass padded OHLCV data
844
904
  layout.overlayYAxisMap, // Pass overlay Y-axis mapping
845
- layout.separatePaneYAxisOffset // Pass Y-axis offset for separate panes
905
+ layout.separatePaneYAxisOffset, // Pass Y-axis offset for separate panes
846
906
  );
847
907
 
848
908
  // Apply barColors to candlestick data
909
+ // TradingView behavior: barcolor() only changes body fill; borders/wicks keep default colors (green/red)
849
910
  const coloredCandlestickData = paddedCandlestickData.map((candle: any, i: number) => {
850
911
  if (barColors[i]) {
912
+ const vals = candle.value || candle;
851
913
  return {
852
- value: candle.value || candle,
914
+ value: vals,
853
915
  itemStyle: {
854
- color: barColors[i],
855
- color0: barColors[i],
856
- borderColor: barColors[i],
857
- borderColor0: barColors[i],
916
+ color: barColors[i], // up-candle body fill
917
+ color0: barColors[i], // down-candle body fill
918
+ // borderColor/borderColor0 intentionally omitted → inherits series default (green/red)
858
919
  },
859
920
  };
860
921
  }
@@ -871,16 +932,7 @@ export class QFChart implements ChartContext {
871
932
  data: coloredCandlestickData,
872
933
  markLine: candlestickSeries.markLine, // Ensure markLine is updated
873
934
  },
874
- ...indicatorSeries.map((s) => {
875
- const update: any = { data: s.data };
876
- // If the series has a renderItem function (custom series like background),
877
- // we MUST update it because it likely closes over variables (colorArray)
878
- // from the SeriesBuilder scope which have been recreated.
879
- if (s.renderItem) {
880
- update.renderItem = s.renderItem;
881
- }
882
- return update;
883
- }),
935
+ ...indicatorSeries,
884
936
  ],
885
937
  };
886
938
 
@@ -897,7 +949,7 @@ export class QFChart implements ChartContext {
897
949
  tables.forEach((t: any) => {
898
950
  if (t && !t._deleted) {
899
951
  // Tag table with its indicator's pane for correct positioning
900
- t._paneIndex = (t.force_overlay) ? 0 : indicator.paneIndex;
952
+ t._paneIndex = t.force_overlay ? 0 : indicator.paneIndex;
901
953
  allTables.push(t);
902
954
  }
903
955
  });
@@ -916,14 +968,23 @@ export class QFChart implements ChartContext {
916
968
  // Stop existing timer
917
969
  this.stopCountdown();
918
970
 
919
- if (!this.options.lastPriceLine?.showCountdown || !this.options.interval || this.marketData.length === 0) {
971
+ if (!this.options.lastPriceLine?.showCountdown || this.marketData.length === 0) {
920
972
  return;
921
973
  }
922
974
 
975
+ // Auto-detect interval from market data if not explicitly set
976
+ let interval = this.options.interval;
977
+ if (!interval && this.marketData.length >= 2) {
978
+ const last = this.marketData[this.marketData.length - 1];
979
+ const prev = this.marketData[this.marketData.length - 2];
980
+ interval = last.time - prev.time;
981
+ }
982
+ if (!interval) return;
983
+
923
984
  const updateLabel = () => {
924
985
  if (this.marketData.length === 0) return;
925
986
  const lastBar = this.marketData[this.marketData.length - 1];
926
- const nextCloseTime = lastBar.time + (this.options.interval || 0);
987
+ const nextCloseTime = lastBar.time + interval!;
927
988
  const now = Date.now();
928
989
  const diff = nextCloseTime - now;
929
990
 
@@ -974,9 +1035,8 @@ export class QFChart implements ChartContext {
974
1035
  if (this.options.yAxisLabelFormatter) {
975
1036
  priceStr = this.options.yAxisLabelFormatter(price);
976
1037
  } else {
977
- const decimals = this.options.yAxisDecimalPlaces !== undefined
978
- ? this.options.yAxisDecimalPlaces
979
- : AxisUtils.autoDetectDecimals(this.marketData);
1038
+ const decimals =
1039
+ this.options.yAxisDecimalPlaces !== undefined ? this.options.yAxisDecimalPlaces : AxisUtils.autoDetectDecimals(this.marketData);
980
1040
  priceStr = AxisUtils.formatValue(price, decimals);
981
1041
  }
982
1042
 
@@ -1030,10 +1090,10 @@ export class QFChart implements ChartContext {
1030
1090
  height?: number;
1031
1091
  titleColor?: string;
1032
1092
  controls?: { collapse?: boolean; maximize?: boolean };
1033
- } = {}
1093
+ } = {},
1034
1094
  ): Indicator {
1035
1095
  // Handle backward compatibility: prefer 'overlay' over 'isOverlay'
1036
- const isOverlay = options.overlay !== undefined ? options.overlay : options.isOverlay ?? false;
1096
+ const isOverlay = options.overlay !== undefined ? options.overlay : (options.isOverlay ?? false);
1037
1097
  let paneIndex = 0;
1038
1098
  if (!isOverlay) {
1039
1099
  // Find the next available pane index
@@ -1110,11 +1170,42 @@ export class QFChart implements ChartContext {
1110
1170
  this._renderTableOverlays();
1111
1171
  }
1112
1172
 
1113
- private _renderTableOverlays(): void {
1173
+ /**
1174
+ * Build table canvas graphic elements from the current _lastTables.
1175
+ * Must be called AFTER setOption so grid rects are available from ECharts.
1176
+ * Returns an array of ECharts graphic elements.
1177
+ */
1178
+ private _buildTableGraphics(): any[] {
1114
1179
  const model = this.chart.getModel() as any;
1115
- const getGridRect = (paneIndex: number) =>
1116
- model.getComponent('grid', paneIndex)?.coordinateSystem?.getRect();
1117
- TableOverlayRenderer.render(this.overlayContainer, this._lastTables, getGridRect);
1180
+ const getGridRect = (paneIndex: number) => model.getComponent('grid', paneIndex)?.coordinateSystem?.getRect();
1181
+ const elements = TableCanvasRenderer.buildGraphicElements(this._lastTables, getGridRect);
1182
+ // Assign stable IDs for future merge/replace
1183
+ this._tableGraphicIds = [];
1184
+ for (let i = 0; i < elements.length; i++) {
1185
+ const id = `__qf_table_${i}`;
1186
+ elements[i].id = id;
1187
+ this._tableGraphicIds.push(id);
1188
+ }
1189
+ return elements;
1190
+ }
1191
+
1192
+ /**
1193
+ * Render table overlays after a non-replacing setOption (updateData, resize).
1194
+ * Uses replaceMerge to cleanly replace all graphic elements without disrupting
1195
+ * other interactive components (dataZoom, tooltip, etc.).
1196
+ */
1197
+ private _renderTableOverlays(): void {
1198
+ // Build new table graphics
1199
+ const tableGraphics = this._buildTableGraphics();
1200
+
1201
+ // Combine base graphics (title, watermark) + table graphics and replace all at once.
1202
+ // Using replaceMerge: ['graphic'] replaces ONLY the graphic component,
1203
+ // leaving dataZoom, tooltip, series etc. untouched.
1204
+ const allGraphics = [...this._baseGraphics, ...tableGraphics];
1205
+ this.chart.setOption({ graphic: allGraphics }, { replaceMerge: ['graphic'] } as any);
1206
+
1207
+ // Clear DOM overlays (legacy) — keep overlay container empty
1208
+ TableOverlayRenderer.clearAll(this.overlayContainer);
1118
1209
  }
1119
1210
 
1120
1211
  public destroy(): void {
@@ -1133,10 +1224,198 @@ export class QFChart implements ChartContext {
1133
1224
  this.timeToIndex.set(k.time, index);
1134
1225
  });
1135
1226
 
1136
- // Update dataIndexOffset whenever data changes
1227
+ // Calculate initial padding from user-configured ratio
1137
1228
  const dataLength = this.marketData.length;
1138
- const paddingPoints = Math.ceil(dataLength * this.padding);
1139
- this.dataIndexOffset = paddingPoints;
1229
+ const initialPadding = Math.ceil(dataLength * this.padding);
1230
+
1231
+ // _paddingPoints can only grow (lazy expansion), never shrink below initial or minimum
1232
+ this._paddingPoints = Math.max(this._paddingPoints, initialPadding, this.LAZY_MIN_PADDING);
1233
+ this.dataIndexOffset = this._paddingPoints;
1234
+ }
1235
+
1236
+ /**
1237
+ * Expand symmetric padding to the given number of points per side.
1238
+ * No-op if newPaddingPoints <= current. Performs a full render() and
1239
+ * restores the viewport position so there is no visual jump.
1240
+ */
1241
+ public expandPadding(newPaddingPoints: number): void {
1242
+ this._resizePadding(newPaddingPoints);
1243
+ }
1244
+
1245
+ /**
1246
+ * Resize symmetric padding to the given number of points per side.
1247
+ * Works for both growing and shrinking. Clamps to [min, max].
1248
+ * Uses merge-mode setOption to preserve drag/interaction state.
1249
+ */
1250
+ private _resizePadding(newPaddingPoints: number): void {
1251
+ // Clamp to bounds
1252
+ const initialPadding = Math.ceil(this.marketData.length * this.padding);
1253
+ newPaddingPoints = Math.max(newPaddingPoints, initialPadding, this.LAZY_MIN_PADDING);
1254
+ newPaddingPoints = Math.min(newPaddingPoints, this.LAZY_MAX_PADDING);
1255
+ if (newPaddingPoints === this._paddingPoints) return;
1256
+
1257
+ // 1. Capture current viewport as absolute bar indices
1258
+ const oldPadding = this._paddingPoints;
1259
+ const oldTotal = this.marketData.length + 2 * oldPadding;
1260
+ const currentOption = this.chart.getOption() as any;
1261
+ const zoomComp = currentOption?.dataZoom?.find((dz: any) => dz.type === 'slider' || dz.type === 'inside');
1262
+ const oldStartIdx = zoomComp ? (zoomComp.start / 100) * oldTotal : 0;
1263
+ const oldEndIdx = zoomComp ? (zoomComp.end / 100) * oldTotal : oldTotal;
1264
+
1265
+ // 2. Update padding state (delta can be positive or negative)
1266
+ const delta = newPaddingPoints - oldPadding;
1267
+ this._paddingPoints = newPaddingPoints;
1268
+ this.dataIndexOffset = this._paddingPoints;
1269
+ const paddingPoints = this._paddingPoints;
1270
+
1271
+ // 3. Rebuild all data arrays with new padding
1272
+ const emptyCandle = { value: [NaN, NaN, NaN, NaN], itemStyle: { opacity: 0 } };
1273
+ const candlestickSeries = SeriesBuilder.buildCandlestickSeries(this.marketData, this.options);
1274
+ const paddedCandlestickData = [
1275
+ ...Array(paddingPoints).fill(emptyCandle),
1276
+ ...candlestickSeries.data,
1277
+ ...Array(paddingPoints).fill(emptyCandle),
1278
+ ];
1279
+ const categoryData = [
1280
+ ...Array(paddingPoints).fill(''),
1281
+ ...this.marketData.map((k) => new Date(k.time).toLocaleString()),
1282
+ ...Array(paddingPoints).fill(''),
1283
+ ];
1284
+ const paddedOHLCVForShapes = [...Array(paddingPoints).fill(null), ...this.marketData, ...Array(paddingPoints).fill(null)];
1285
+
1286
+ // Rebuild indicator series with new offset
1287
+ const layout = LayoutManager.calculate(
1288
+ this.chart.getHeight(),
1289
+ this.indicators,
1290
+ this.options,
1291
+ this.isMainCollapsed,
1292
+ this.maximizedPaneId,
1293
+ this.marketData,
1294
+ this._mainHeightOverride ?? undefined,
1295
+ );
1296
+ const { series: indicatorSeries, barColors } = SeriesBuilder.buildIndicatorSeries(
1297
+ this.indicators,
1298
+ this.timeToIndex,
1299
+ layout.paneLayout,
1300
+ categoryData.length,
1301
+ paddingPoints,
1302
+ paddedOHLCVForShapes,
1303
+ layout.overlayYAxisMap,
1304
+ layout.separatePaneYAxisOffset,
1305
+ );
1306
+
1307
+ // Apply barColors (TradingView: barcolor() only changes body fill, borders/wicks stay default)
1308
+ const coloredCandlestickData = paddedCandlestickData.map((candle: any, i: number) => {
1309
+ if (barColors[i]) {
1310
+ const vals = candle.value || candle;
1311
+ return {
1312
+ value: vals,
1313
+ itemStyle: {
1314
+ color: barColors[i],
1315
+ color0: barColors[i],
1316
+ },
1317
+ };
1318
+ }
1319
+ return candle;
1320
+ });
1321
+
1322
+ // 4. Calculate corrected zoom for new total length
1323
+ const newTotal = this.marketData.length + 2 * newPaddingPoints;
1324
+ const newStart = Math.max(0, ((oldStartIdx + delta) / newTotal) * 100);
1325
+ const newEnd = Math.min(100, ((oldEndIdx + delta) / newTotal) * 100);
1326
+
1327
+ // 5. Rebuild drawing series data with new offset so ECharts
1328
+ // viewport culling uses correct padded indices after expansion.
1329
+ const drawingSeriesUpdates: any[] = [];
1330
+ const drawingsByPane = new Map<number, import('./types').DrawingElement[]>();
1331
+ this.drawings.forEach((d) => {
1332
+ const paneIdx = d.paneIndex || 0;
1333
+ if (!drawingsByPane.has(paneIdx)) drawingsByPane.set(paneIdx, []);
1334
+ drawingsByPane.get(paneIdx)!.push(d);
1335
+ });
1336
+ drawingsByPane.forEach((paneDrawings) => {
1337
+ drawingSeriesUpdates.push({
1338
+ data: paneDrawings.map((d) => [
1339
+ d.points[0].timeIndex + this.dataIndexOffset,
1340
+ d.points[0].value,
1341
+ d.points[1].timeIndex + this.dataIndexOffset,
1342
+ d.points[1].value,
1343
+ ]),
1344
+ });
1345
+ });
1346
+
1347
+ // 6. Merge update — preserves drag/interaction state
1348
+ const updateOption: any = {
1349
+ xAxis: currentOption.xAxis.map(() => ({ data: categoryData })),
1350
+ dataZoom: [
1351
+ { start: newStart, end: newEnd },
1352
+ { start: newStart, end: newEnd },
1353
+ ],
1354
+ series: [
1355
+ { data: coloredCandlestickData, markLine: candlestickSeries.markLine },
1356
+ ...indicatorSeries.map((s) => {
1357
+ const update: any = { data: s.data };
1358
+ if (s.renderItem) update.renderItem = s.renderItem;
1359
+ return update;
1360
+ }),
1361
+ ...drawingSeriesUpdates,
1362
+ ],
1363
+ };
1364
+ this.chart.setOption(updateOption, { notMerge: false });
1365
+ }
1366
+
1367
+ /**
1368
+ * Check if user scrolled near an edge (expand) or away from edges (contract).
1369
+ * Uses requestAnimationFrame to avoid cascading re-renders inside
1370
+ * the ECharts dataZoom event callback.
1371
+ */
1372
+ private _checkEdgeAndExpand(): void {
1373
+ if (this._expandScheduled) return;
1374
+
1375
+ const zoomComp = (this.chart.getOption() as any)?.dataZoom?.find((dz: any) => dz.type === 'slider' || dz.type === 'inside');
1376
+ if (!zoomComp) return;
1377
+
1378
+ const paddingPoints = this._paddingPoints;
1379
+ const dataLength = this.marketData.length;
1380
+ const totalLength = dataLength + 2 * paddingPoints;
1381
+ const startIdx = Math.round((zoomComp.start / 100) * totalLength);
1382
+ const endIdx = Math.round((zoomComp.end / 100) * totalLength);
1383
+
1384
+ // Count visible real candles (overlap between viewport and data range)
1385
+ const dataStart = paddingPoints;
1386
+ const dataEnd = paddingPoints + dataLength - 1;
1387
+ const visibleCandles = Math.max(0, Math.min(endIdx, dataEnd) - Math.max(startIdx, dataStart) + 1);
1388
+
1389
+ const nearLeftEdge = startIdx < this.LAZY_EDGE_THRESHOLD;
1390
+ const nearRightEdge = endIdx > totalLength - this.LAZY_EDGE_THRESHOLD;
1391
+
1392
+ // Don't expand when zoomed in very tight (fewer than 3 visible candles)
1393
+ if ((nearLeftEdge || nearRightEdge) && paddingPoints < this.LAZY_MAX_PADDING && visibleCandles >= 3) {
1394
+ this._expandScheduled = true;
1395
+ requestAnimationFrame(() => {
1396
+ this._expandScheduled = false;
1397
+ this._resizePadding(paddingPoints + this.LAZY_CHUNK_SIZE);
1398
+ });
1399
+ return;
1400
+ }
1401
+
1402
+ // Contract if far from both edges and padding is larger than needed
1403
+ // Calculate how many padding bars are visible/near-visible on each side
1404
+ const leftPadUsed = Math.max(0, paddingPoints - startIdx);
1405
+ const rightPadUsed = Math.max(0, endIdx - (paddingPoints + dataLength - 1));
1406
+ const neededPadding = Math.max(
1407
+ leftPadUsed + this.LAZY_CHUNK_SIZE, // keep one chunk of buffer
1408
+ rightPadUsed + this.LAZY_CHUNK_SIZE,
1409
+ );
1410
+
1411
+ // Only contract if we have at least one full chunk of excess
1412
+ if (paddingPoints > neededPadding + this.LAZY_CHUNK_SIZE) {
1413
+ this._expandScheduled = true;
1414
+ requestAnimationFrame(() => {
1415
+ this._expandScheduled = false;
1416
+ this._resizePadding(neededPadding);
1417
+ });
1418
+ }
1140
1419
  }
1141
1420
 
1142
1421
  private render(): void {
@@ -1258,19 +1537,18 @@ export class QFChart implements ChartContext {
1258
1537
  paddingPoints,
1259
1538
  paddedOHLCVForShapes, // Pass padded OHLCV
1260
1539
  layout.overlayYAxisMap, // Pass overlay Y-axis mapping
1261
- layout.separatePaneYAxisOffset // Pass Y-axis offset for separate panes
1540
+ layout.separatePaneYAxisOffset, // Pass Y-axis offset for separate panes
1262
1541
  );
1263
1542
 
1264
- // Apply barColors to candlestick data
1543
+ // Apply barColors (TradingView: barcolor() only changes body fill, borders/wicks stay default)
1265
1544
  candlestickSeries.data = candlestickSeries.data.map((candle: any, i: number) => {
1266
1545
  if (barColors[i]) {
1546
+ const vals = candle.value || candle;
1267
1547
  return {
1268
- value: candle.value || candle,
1548
+ value: vals,
1269
1549
  itemStyle: {
1270
1550
  color: barColors[i],
1271
1551
  color0: barColors[i],
1272
- borderColor: barColors[i],
1273
- borderColor0: barColors[i],
1274
1552
  },
1275
1553
  };
1276
1554
  }
@@ -1278,7 +1556,20 @@ export class QFChart implements ChartContext {
1278
1556
  });
1279
1557
 
1280
1558
  // 3. Build Graphics
1281
- const graphic = GraphicBuilder.build(layout, this.options, this.toggleIndicator.bind(this), this.isMainCollapsed, this.maximizedPaneId);
1559
+ const overlayIndicators: { id: string; titleColor?: string }[] = [];
1560
+ this.indicators.forEach((ind, id) => {
1561
+ if (ind.paneIndex === 0) {
1562
+ overlayIndicators.push({ id, titleColor: ind.titleColor });
1563
+ }
1564
+ });
1565
+ const graphic = GraphicBuilder.build(
1566
+ layout,
1567
+ this.options,
1568
+ this.toggleIndicator.bind(this),
1569
+ this.isMainCollapsed,
1570
+ this.maximizedPaneId,
1571
+ overlayIndicators,
1572
+ );
1282
1573
 
1283
1574
  // 4. Build Drawings Series (One Custom Series per Pane used)
1284
1575
  const drawingsByPane = new Map<number, import('./types').DrawingElement[]>();
@@ -1307,9 +1598,10 @@ export class QFChart implements ChartContext {
1307
1598
 
1308
1599
  if (!start || !end) return;
1309
1600
 
1310
- // Coordinates are already in padded space, use directly
1311
- const p1 = api.coord([start.timeIndex, start.value]);
1312
- const p2 = api.coord([end.timeIndex, end.value]);
1601
+ // Convert real data indices to padded space for ECharts rendering
1602
+ const drawingOffset = this.dataIndexOffset;
1603
+ const p1 = api.coord([start.timeIndex + drawingOffset, start.value]);
1604
+ const p2 = api.coord([end.timeIndex + drawingOffset, end.value]);
1313
1605
 
1314
1606
  const isSelected = drawing.id === this.selectedDrawingId;
1315
1607
 
@@ -1548,7 +1840,12 @@ export class QFChart implements ChartContext {
1548
1840
  };
1549
1841
  }
1550
1842
  },
1551
- data: drawings.map((d) => [d.points[0].timeIndex, d.points[0].value, d.points[1].timeIndex, d.points[1].value]),
1843
+ data: drawings.map((d) => [
1844
+ d.points[0].timeIndex + this.dataIndexOffset,
1845
+ d.points[0].value,
1846
+ d.points[1].timeIndex + this.dataIndexOffset,
1847
+ d.points[1].value,
1848
+ ]),
1552
1849
  encode: { x: [0, 2], y: [1, 3] },
1553
1850
  z: 100,
1554
1851
  silent: false,
@@ -1587,7 +1884,7 @@ export class QFChart implements ChartContext {
1587
1884
  tables.forEach((t: any) => {
1588
1885
  if (t && !t._deleted) {
1589
1886
  // Tag table with its indicator's pane for correct positioning
1590
- t._paneIndex = (t.force_overlay) ? 0 : indicator.paneIndex;
1887
+ t._paneIndex = t.force_overlay ? 0 : indicator.paneIndex;
1591
1888
  allTables.push(t);
1592
1889
  }
1593
1890
  });
@@ -1641,8 +1938,24 @@ export class QFChart implements ChartContext {
1641
1938
 
1642
1939
  this.chart.setOption(option, true); // true = not merge, replace.
1643
1940
 
1644
- // Render table overlays AFTER setOption so we can query the computed grid rect
1941
+ // Store base graphics (title, watermark, pane labels) for later re-use
1942
+ // in _renderTableOverlays so we can do a clean replaceMerge.
1943
+ this._baseGraphics = graphic;
1944
+
1945
+ // Render table graphics AFTER setOption so we can query the computed grid rects.
1946
+ // Uses replaceMerge to cleanly set all graphics without disrupting interactive components.
1645
1947
  this._lastTables = allTables;
1646
- this._renderTableOverlays();
1948
+ if (allTables.length > 0) {
1949
+ const tableGraphics = this._buildTableGraphics();
1950
+ if (tableGraphics.length > 0) {
1951
+ const allGraphics = [...graphic, ...tableGraphics];
1952
+ this.chart.setOption({ graphic: allGraphics }, { replaceMerge: ['graphic'] } as any);
1953
+ }
1954
+ } else {
1955
+ this._tableGraphicIds = [];
1956
+ }
1957
+
1958
+ // Clear DOM overlays (legacy)
1959
+ TableOverlayRenderer.clearAll(this.overlayContainer);
1647
1960
  }
1648
1961
  }