@qfo/qfchart 0.7.2 → 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
@@ -1,7 +1,7 @@
1
1
  import * as echarts from 'echarts';
2
2
  import { OHLCV, IndicatorPlot, QFChartOptions, Indicator as IndicatorInterface, ChartContext, Plugin } from './types';
3
3
  import { Indicator } from './components/Indicator';
4
- import { LayoutManager } from './components/LayoutManager';
4
+ import { LayoutManager, LayoutResult, PaneBoundary } from './components/LayoutManager';
5
5
  import { SeriesBuilder } from './components/SeriesBuilder';
6
6
  import { GraphicBuilder } from './components/GraphicBuilder';
7
7
  import { TooltipFormatter } from './components/TooltipFormatter';
@@ -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,21 @@ 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()
104
+
105
+ // Pane drag-resize state
106
+ private _lastLayout: (LayoutResult & { overlayYAxisMap: Map<string, number>; separatePaneYAxisOffset: number }) | null = null;
107
+ private _mainHeightOverride: number | null = null;
108
+ private _paneDragState: {
109
+ startY: number;
110
+ aboveId: string | 'main';
111
+ belowId: string;
112
+ startAboveHeight: number;
113
+ startBelowHeight: number;
114
+ } | null = null;
115
+ private _paneResizeRafId: number | null = null;
92
116
 
93
117
  constructor(container: HTMLElement, options: QFChartOptions = {}) {
94
118
  this.rootContainer = container;
@@ -183,7 +207,8 @@ export class QFChart implements ChartContext {
183
207
  // Overlay container for table rendering (positioned above ECharts canvas)
184
208
  this.chartContainer.style.position = 'relative';
185
209
  this.overlayContainer = document.createElement('div');
186
- 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;';
187
212
  this.chartContainer.appendChild(this.overlayContainer);
188
213
 
189
214
  this.pluginManager = new PluginManager(this, this.toolbarContainer);
@@ -197,26 +222,38 @@ export class QFChart implements ChartContext {
197
222
  const triggerOn = this.options.databox?.triggerOn;
198
223
  const position = this.options.databox?.position;
199
224
  if (triggerOn === 'click' && position === 'floating') {
200
- // Hide tooltip by dispatching a hideTooltip action
201
- this.chart.dispatchAction({
202
- type: 'hideTip',
203
- });
225
+ this.chart.dispatchAction({ type: 'hideTip' });
204
226
  }
227
+
228
+ // Lazy padding: check if user scrolled near an edge
229
+ this._checkEdgeAndExpand();
205
230
  });
206
231
  // @ts-ignore - ECharts event handler type mismatch
207
232
  this.chart.on('finished', (params: any) => this.events.emit('chart:updated', params)); // General chart update
208
233
  // @ts-ignore - ECharts ZRender event handler type mismatch
209
- this.chart.getZr().on('mousedown', (params: any) => 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
+ });
210
237
  // @ts-ignore - ECharts ZRender event handler type mismatch
211
- this.chart.getZr().on('mousemove', (params: any) => 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
+ });
212
241
  // @ts-ignore - ECharts ZRender event handler type mismatch
213
242
  this.chart.getZr().on('mouseup', (params: any) => this.events.emit('mouse:up', params));
214
243
  // @ts-ignore - ECharts ZRender event handler type mismatch
215
- this.chart.getZr().on('click', (params: any) => 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
+ });
216
247
 
217
248
  const zr = this.chart.getZr();
218
249
  const originalSetCursorStyle = zr.setCursorStyle;
250
+ const self = this;
219
251
  zr.setCursorStyle = function (cursorStyle: string) {
252
+ // During pane drag, force row-resize cursor
253
+ if (self._paneDragState) {
254
+ originalSetCursorStyle.call(this, 'row-resize');
255
+ return;
256
+ }
220
257
  // Change 'grab' (default roam cursor) to 'crosshair' (more suitable for candlestick chart)
221
258
  if (cursorStyle === 'grab') {
222
259
  cursorStyle = 'crosshair';
@@ -228,6 +265,9 @@ export class QFChart implements ChartContext {
228
265
  // Bind Drawing Events
229
266
  this.bindDrawingEvents();
230
267
 
268
+ // Bind pane border drag-resize
269
+ this.bindPaneResizeEvents();
270
+
231
271
  window.addEventListener('resize', this.resize.bind(this));
232
272
 
233
273
  // Listen for fullscreen change to restore state if exited via ESC
@@ -252,6 +292,133 @@ export class QFChart implements ChartContext {
252
292
  this.render();
253
293
  };
254
294
 
295
+ // ── Pane border drag-resize ────────────────────────────────
296
+ private bindPaneResizeEvents(): void {
297
+ const MIN_MAIN = 10; // minimum main pane height %
298
+ const MIN_INDICATOR = 5; // minimum indicator pane height %
299
+ const HIT_ZONE = 6; // hit-zone in pixels (±3px from boundary center)
300
+
301
+ const zr = this.chart.getZr();
302
+
303
+ /** Find a boundary near the mouse Y position (pixels). */
304
+ const findBoundary = (mouseY: number): PaneBoundary | null => {
305
+ if (!this._lastLayout || this._lastLayout.paneBoundaries.length === 0) return null;
306
+ if (this.maximizedPaneId) return null; // no resize when maximized
307
+ const containerH = this.chart.getHeight();
308
+ if (containerH <= 0) return null;
309
+
310
+ for (const b of this._lastLayout.paneBoundaries) {
311
+ const bY = (b.yPercent / 100) * containerH;
312
+ if (Math.abs(mouseY - bY) <= HIT_ZONE) {
313
+ // Don't allow resizing collapsed panes
314
+ if (b.aboveId === 'main' && this.isMainCollapsed) continue;
315
+ const belowInd = this.indicators.get(b.belowId);
316
+ if (belowInd?.collapsed) continue;
317
+ if (b.aboveId !== 'main') {
318
+ const aboveInd = this.indicators.get(b.aboveId);
319
+ if (aboveInd?.collapsed) continue;
320
+ }
321
+ return b;
322
+ }
323
+ }
324
+ return null;
325
+ };
326
+
327
+ /** Get current height of a pane. */
328
+ const getPaneHeight = (id: string | 'main'): number => {
329
+ if (id === 'main') {
330
+ return this._lastLayout?.mainPaneHeight ?? 50;
331
+ }
332
+ const ind = this.indicators.get(id);
333
+ return ind?.height ?? 15;
334
+ };
335
+
336
+ // --- ZR event handlers ---
337
+
338
+ zr.on('mousemove', (e: any) => {
339
+ if (this._paneDragState) {
340
+ // Active drag: compute new heights
341
+ const deltaY = e.offsetY - this._paneDragState.startY;
342
+ const containerH = this.chart.getHeight();
343
+ if (containerH <= 0) return;
344
+ const deltaPct = (deltaY / containerH) * 100;
345
+
346
+ const minAbove = this._paneDragState.aboveId === 'main' ? MIN_MAIN : MIN_INDICATOR;
347
+ const minBelow = MIN_INDICATOR;
348
+
349
+ let newAbove = this._paneDragState.startAboveHeight + deltaPct;
350
+ let newBelow = this._paneDragState.startBelowHeight - deltaPct;
351
+
352
+ // Clamp
353
+ if (newAbove < minAbove) {
354
+ newAbove = minAbove;
355
+ newBelow = this._paneDragState.startAboveHeight + this._paneDragState.startBelowHeight - minAbove;
356
+ }
357
+ if (newBelow < minBelow) {
358
+ newBelow = minBelow;
359
+ newAbove = this._paneDragState.startAboveHeight + this._paneDragState.startBelowHeight - minBelow;
360
+ }
361
+
362
+ // Apply heights
363
+ if (this._paneDragState.aboveId === 'main') {
364
+ this._mainHeightOverride = newAbove;
365
+ } else {
366
+ const aboveInd = this.indicators.get(this._paneDragState.aboveId);
367
+ if (aboveInd) aboveInd.height = newAbove;
368
+ }
369
+ const belowInd = this.indicators.get(this._paneDragState.belowId);
370
+ if (belowInd) belowInd.height = newBelow;
371
+
372
+ // Throttle re-render via rAF
373
+ if (!this._paneResizeRafId) {
374
+ this._paneResizeRafId = requestAnimationFrame(() => {
375
+ this._paneResizeRafId = null;
376
+ this.render();
377
+ });
378
+ }
379
+
380
+ // Force row-resize cursor
381
+ zr.setCursorStyle('row-resize');
382
+ e.stop?.();
383
+ return;
384
+ }
385
+
386
+ // Not dragging: check hover over boundary
387
+ const boundary = findBoundary(e.offsetY);
388
+ if (boundary) {
389
+ zr.setCursorStyle('row-resize');
390
+ }
391
+ });
392
+
393
+ zr.on('mousedown', (e: any) => {
394
+ const boundary = findBoundary(e.offsetY);
395
+ if (!boundary) return;
396
+
397
+ // Start drag
398
+ this._paneDragState = {
399
+ startY: e.offsetY,
400
+ aboveId: boundary.aboveId,
401
+ belowId: boundary.belowId,
402
+ startAboveHeight: getPaneHeight(boundary.aboveId),
403
+ startBelowHeight: getPaneHeight(boundary.belowId),
404
+ };
405
+
406
+ zr.setCursorStyle('row-resize');
407
+ e.stop?.();
408
+ });
409
+
410
+ zr.on('mouseup', () => {
411
+ if (this._paneDragState) {
412
+ this._paneDragState = null;
413
+ if (this._paneResizeRafId) {
414
+ cancelAnimationFrame(this._paneResizeRafId);
415
+ this._paneResizeRafId = null;
416
+ }
417
+ this.render();
418
+ }
419
+ });
420
+ }
421
+
255
422
  private bindDrawingEvents() {
256
423
  let hideTimeout: any = null;
257
424
  let lastHoveredGroup: any = null;
@@ -450,6 +617,47 @@ export class QFChart implements ChartContext {
450
617
  }
451
618
  }
452
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
+ });
453
661
  }
454
662
 
455
663
  // --- Plugin System Integration ---
@@ -669,8 +877,10 @@ export class QFChart implements ChartContext {
669
877
  this.options,
670
878
  this.isMainCollapsed,
671
879
  this.maximizedPaneId,
672
- this.marketData
880
+ this.marketData,
881
+ this._mainHeightOverride ?? undefined,
673
882
  );
883
+ this._lastLayout = layout;
674
884
 
675
885
  // Pass full padded candlestick data for shape positioning
676
886
  // But SeriesBuilder expects 'OHLCV[]', while paddedCandlestickData is array of arrays [open,close,low,high]
@@ -692,19 +902,20 @@ export class QFChart implements ChartContext {
692
902
  paddingPoints,
693
903
  paddedOHLCVForShapes, // Pass padded OHLCV data
694
904
  layout.overlayYAxisMap, // Pass overlay Y-axis mapping
695
- layout.separatePaneYAxisOffset // Pass Y-axis offset for separate panes
905
+ layout.separatePaneYAxisOffset, // Pass Y-axis offset for separate panes
696
906
  );
697
907
 
698
908
  // Apply barColors to candlestick data
909
+ // TradingView behavior: barcolor() only changes body fill; borders/wicks keep default colors (green/red)
699
910
  const coloredCandlestickData = paddedCandlestickData.map((candle: any, i: number) => {
700
911
  if (barColors[i]) {
912
+ const vals = candle.value || candle;
701
913
  return {
702
- value: candle.value || candle,
914
+ value: vals,
703
915
  itemStyle: {
704
- color: barColors[i],
705
- color0: barColors[i],
706
- borderColor: barColors[i],
707
- 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)
708
919
  },
709
920
  };
710
921
  }
@@ -721,16 +932,7 @@ export class QFChart implements ChartContext {
721
932
  data: coloredCandlestickData,
722
933
  markLine: candlestickSeries.markLine, // Ensure markLine is updated
723
934
  },
724
- ...indicatorSeries.map((s) => {
725
- const update: any = { data: s.data };
726
- // If the series has a renderItem function (custom series like background),
727
- // we MUST update it because it likely closes over variables (colorArray)
728
- // from the SeriesBuilder scope which have been recreated.
729
- if (s.renderItem) {
730
- update.renderItem = s.renderItem;
731
- }
732
- return update;
733
- }),
935
+ ...indicatorSeries,
734
936
  ],
735
937
  };
736
938
 
@@ -745,7 +947,11 @@ export class QFChart implements ChartContext {
745
947
  plot.data?.forEach((entry: any) => {
746
948
  const tables = Array.isArray(entry.value) ? entry.value : [entry.value];
747
949
  tables.forEach((t: any) => {
748
- if (t && !t._deleted) allTables.push(t);
950
+ if (t && !t._deleted) {
951
+ // Tag table with its indicator's pane for correct positioning
952
+ t._paneIndex = t.force_overlay ? 0 : indicator.paneIndex;
953
+ allTables.push(t);
954
+ }
749
955
  });
750
956
  });
751
957
  }
@@ -762,14 +968,23 @@ export class QFChart implements ChartContext {
762
968
  // Stop existing timer
763
969
  this.stopCountdown();
764
970
 
765
- if (!this.options.lastPriceLine?.showCountdown || !this.options.interval || this.marketData.length === 0) {
971
+ if (!this.options.lastPriceLine?.showCountdown || this.marketData.length === 0) {
766
972
  return;
767
973
  }
768
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
+
769
984
  const updateLabel = () => {
770
985
  if (this.marketData.length === 0) return;
771
986
  const lastBar = this.marketData[this.marketData.length - 1];
772
- const nextCloseTime = lastBar.time + (this.options.interval || 0);
987
+ const nextCloseTime = lastBar.time + interval!;
773
988
  const now = Date.now();
774
989
  const diff = nextCloseTime - now;
775
990
 
@@ -820,9 +1035,8 @@ export class QFChart implements ChartContext {
820
1035
  if (this.options.yAxisLabelFormatter) {
821
1036
  priceStr = this.options.yAxisLabelFormatter(price);
822
1037
  } else {
823
- const decimals = this.options.yAxisDecimalPlaces !== undefined
824
- ? this.options.yAxisDecimalPlaces
825
- : AxisUtils.autoDetectDecimals(this.marketData);
1038
+ const decimals =
1039
+ this.options.yAxisDecimalPlaces !== undefined ? this.options.yAxisDecimalPlaces : AxisUtils.autoDetectDecimals(this.marketData);
826
1040
  priceStr = AxisUtils.formatValue(price, decimals);
827
1041
  }
828
1042
 
@@ -876,10 +1090,10 @@ export class QFChart implements ChartContext {
876
1090
  height?: number;
877
1091
  titleColor?: string;
878
1092
  controls?: { collapse?: boolean; maximize?: boolean };
879
- } = {}
1093
+ } = {},
880
1094
  ): Indicator {
881
1095
  // Handle backward compatibility: prefer 'overlay' over 'isOverlay'
882
- const isOverlay = options.overlay !== undefined ? options.overlay : options.isOverlay ?? false;
1096
+ const isOverlay = options.overlay !== undefined ? options.overlay : (options.isOverlay ?? false);
883
1097
  let paneIndex = 0;
884
1098
  if (!isOverlay) {
885
1099
  // Find the next available pane index
@@ -956,9 +1170,42 @@ export class QFChart implements ChartContext {
956
1170
  this._renderTableOverlays();
957
1171
  }
958
1172
 
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[] {
1179
+ const model = this.chart.getModel() as any;
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
+ */
959
1197
  private _renderTableOverlays(): void {
960
- const gridRect = (this.chart.getModel() as any).getComponent('grid', 0)?.coordinateSystem?.getRect();
961
- TableOverlayRenderer.render(this.overlayContainer, this._lastTables, gridRect);
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);
962
1209
  }
963
1210
 
964
1211
  public destroy(): void {
@@ -977,10 +1224,198 @@ export class QFChart implements ChartContext {
977
1224
  this.timeToIndex.set(k.time, index);
978
1225
  });
979
1226
 
980
- // Update dataIndexOffset whenever data changes
1227
+ // Calculate initial padding from user-configured ratio
1228
+ const dataLength = this.marketData.length;
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;
981
1379
  const dataLength = this.marketData.length;
982
- const paddingPoints = Math.ceil(dataLength * this.padding);
983
- this.dataIndexOffset = paddingPoints;
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
+ }
984
1419
  }
985
1420
 
986
1421
  private render(): void {
@@ -1040,8 +1475,10 @@ export class QFChart implements ChartContext {
1040
1475
  this.options,
1041
1476
  this.isMainCollapsed,
1042
1477
  this.maximizedPaneId,
1043
- this.marketData
1478
+ this.marketData,
1479
+ this._mainHeightOverride ?? undefined,
1044
1480
  );
1481
+ this._lastLayout = layout;
1045
1482
 
1046
1483
  // Convert user-provided dataZoom start/end to account for padding
1047
1484
  // User's start/end refer to real data (0% = start of real data, 100% = end of real data)
@@ -1100,19 +1537,18 @@ export class QFChart implements ChartContext {
1100
1537
  paddingPoints,
1101
1538
  paddedOHLCVForShapes, // Pass padded OHLCV
1102
1539
  layout.overlayYAxisMap, // Pass overlay Y-axis mapping
1103
- layout.separatePaneYAxisOffset // Pass Y-axis offset for separate panes
1540
+ layout.separatePaneYAxisOffset, // Pass Y-axis offset for separate panes
1104
1541
  );
1105
1542
 
1106
- // Apply barColors to candlestick data
1543
+ // Apply barColors (TradingView: barcolor() only changes body fill, borders/wicks stay default)
1107
1544
  candlestickSeries.data = candlestickSeries.data.map((candle: any, i: number) => {
1108
1545
  if (barColors[i]) {
1546
+ const vals = candle.value || candle;
1109
1547
  return {
1110
- value: candle.value || candle,
1548
+ value: vals,
1111
1549
  itemStyle: {
1112
1550
  color: barColors[i],
1113
1551
  color0: barColors[i],
1114
- borderColor: barColors[i],
1115
- borderColor0: barColors[i],
1116
1552
  },
1117
1553
  };
1118
1554
  }
@@ -1120,7 +1556,20 @@ export class QFChart implements ChartContext {
1120
1556
  });
1121
1557
 
1122
1558
  // 3. Build Graphics
1123
- 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
+ );
1124
1573
 
1125
1574
  // 4. Build Drawings Series (One Custom Series per Pane used)
1126
1575
  const drawingsByPane = new Map<number, import('./types').DrawingElement[]>();
@@ -1149,9 +1598,10 @@ export class QFChart implements ChartContext {
1149
1598
 
1150
1599
  if (!start || !end) return;
1151
1600
 
1152
- // Coordinates are already in padded space, use directly
1153
- const p1 = api.coord([start.timeIndex, start.value]);
1154
- 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]);
1155
1605
 
1156
1606
  const isSelected = drawing.id === this.selectedDrawingId;
1157
1607
 
@@ -1390,7 +1840,12 @@ export class QFChart implements ChartContext {
1390
1840
  };
1391
1841
  }
1392
1842
  },
1393
- 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
+ ]),
1394
1849
  encode: { x: [0, 2], y: [1, 3] },
1395
1850
  z: 100,
1396
1851
  silent: false,
@@ -1427,7 +1882,11 @@ export class QFChart implements ChartContext {
1427
1882
  plot.data?.forEach((entry: any) => {
1428
1883
  const tables = Array.isArray(entry.value) ? entry.value : [entry.value];
1429
1884
  tables.forEach((t: any) => {
1430
- if (t && !t._deleted) allTables.push(t);
1885
+ if (t && !t._deleted) {
1886
+ // Tag table with its indicator's pane for correct positioning
1887
+ t._paneIndex = t.force_overlay ? 0 : indicator.paneIndex;
1888
+ allTables.push(t);
1889
+ }
1431
1890
  });
1432
1891
  });
1433
1892
  }
@@ -1479,8 +1938,24 @@ export class QFChart implements ChartContext {
1479
1938
 
1480
1939
  this.chart.setOption(option, true); // true = not merge, replace.
1481
1940
 
1482
- // 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.
1483
1947
  this._lastTables = allTables;
1484
- 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);
1485
1960
  }
1486
1961
  }