@qfo/qfchart 0.7.3 → 0.8.1

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.
Files changed (59) hide show
  1. package/dist/index.d.ts +368 -14
  2. package/dist/qfchart.min.browser.js +34 -16
  3. package/dist/qfchart.min.es.js +34 -16
  4. package/package.json +1 -1
  5. package/src/QFChart.ts +460 -311
  6. package/src/components/AbstractPlugin.ts +234 -104
  7. package/src/components/DrawingEditor.ts +297 -248
  8. package/src/components/DrawingRendererRegistry.ts +13 -0
  9. package/src/components/GraphicBuilder.ts +284 -263
  10. package/src/components/LayoutManager.ts +72 -55
  11. package/src/components/SeriesBuilder.ts +110 -6
  12. package/src/components/TableCanvasRenderer.ts +467 -0
  13. package/src/components/TableOverlayRenderer.ts +38 -9
  14. package/src/components/TooltipFormatter.ts +97 -97
  15. package/src/components/renderers/BackgroundRenderer.ts +59 -47
  16. package/src/components/renderers/BoxRenderer.ts +113 -17
  17. package/src/components/renderers/FillRenderer.ts +118 -3
  18. package/src/components/renderers/LabelRenderer.ts +35 -9
  19. package/src/components/renderers/OHLCBarRenderer.ts +171 -161
  20. package/src/components/renderers/PolylineRenderer.ts +26 -19
  21. package/src/index.ts +17 -6
  22. package/src/plugins/ABCDPatternTool/ABCDPatternDrawingRenderer.ts +112 -0
  23. package/src/plugins/ABCDPatternTool/ABCDPatternTool.ts +136 -0
  24. package/src/plugins/ABCDPatternTool/index.ts +2 -0
  25. package/src/plugins/CypherPatternTool/CypherPatternDrawingRenderer.ts +80 -0
  26. package/src/plugins/CypherPatternTool/CypherPatternTool.ts +84 -0
  27. package/src/plugins/CypherPatternTool/index.ts +2 -0
  28. package/src/plugins/FibSpeedResistanceFanTool/FibSpeedResistanceFanDrawingRenderer.ts +163 -0
  29. package/src/plugins/FibSpeedResistanceFanTool/FibSpeedResistanceFanTool.ts +210 -0
  30. package/src/plugins/FibSpeedResistanceFanTool/index.ts +2 -0
  31. package/src/plugins/FibTrendExtensionTool/FibTrendExtensionDrawingRenderer.ts +141 -0
  32. package/src/plugins/FibTrendExtensionTool/FibTrendExtensionTool.ts +188 -0
  33. package/src/plugins/FibTrendExtensionTool/index.ts +2 -0
  34. package/src/plugins/FibonacciChannelTool/FibonacciChannelDrawingRenderer.ts +128 -0
  35. package/src/plugins/FibonacciChannelTool/FibonacciChannelTool.ts +231 -0
  36. package/src/plugins/FibonacciChannelTool/index.ts +2 -0
  37. package/src/plugins/FibonacciTool/FibonacciDrawingRenderer.ts +107 -0
  38. package/src/plugins/{FibonacciTool.ts → FibonacciTool/FibonacciTool.ts} +195 -192
  39. package/src/plugins/FibonacciTool/index.ts +2 -0
  40. package/src/plugins/HeadAndShouldersTool/HeadAndShouldersDrawingRenderer.ts +95 -0
  41. package/src/plugins/HeadAndShouldersTool/HeadAndShouldersTool.ts +97 -0
  42. package/src/plugins/HeadAndShouldersTool/index.ts +2 -0
  43. package/src/plugins/LineTool/LineDrawingRenderer.ts +49 -0
  44. package/src/plugins/{LineTool.ts → LineTool/LineTool.ts} +161 -190
  45. package/src/plugins/LineTool/index.ts +2 -0
  46. package/src/plugins/{MeasureTool.ts → MeasureTool/MeasureTool.ts} +324 -344
  47. package/src/plugins/MeasureTool/index.ts +1 -0
  48. package/src/plugins/ThreeDrivesPatternTool/ThreeDrivesPatternDrawingRenderer.ts +106 -0
  49. package/src/plugins/ThreeDrivesPatternTool/ThreeDrivesPatternTool.ts +98 -0
  50. package/src/plugins/ThreeDrivesPatternTool/index.ts +2 -0
  51. package/src/plugins/ToolGroup.ts +211 -0
  52. package/src/plugins/TrianglePatternTool/TrianglePatternDrawingRenderer.ts +107 -0
  53. package/src/plugins/TrianglePatternTool/TrianglePatternTool.ts +98 -0
  54. package/src/plugins/TrianglePatternTool/index.ts +2 -0
  55. package/src/plugins/XABCDPatternTool/XABCDPatternDrawingRenderer.ts +178 -0
  56. package/src/plugins/XABCDPatternTool/XABCDPatternTool.ts +213 -0
  57. package/src/plugins/XABCDPatternTool/index.ts +2 -0
  58. package/src/types.ts +39 -4
  59. package/src/utils/ColorUtils.ts +1 -1
package/src/QFChart.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as echarts from 'echarts';
2
- import { OHLCV, IndicatorPlot, QFChartOptions, Indicator as IndicatorInterface, ChartContext, Plugin } from './types';
2
+ import { OHLCV, IndicatorPlot, QFChartOptions, Indicator as IndicatorInterface, ChartContext, Plugin, DrawingRenderer } from './types';
3
3
  import { Indicator } from './components/Indicator';
4
4
  import { LayoutManager, LayoutResult, PaneBoundary } from './components/LayoutManager';
5
5
  import { SeriesBuilder } from './components/SeriesBuilder';
@@ -7,9 +7,11 @@ import { GraphicBuilder } from './components/GraphicBuilder';
7
7
  import { TooltipFormatter } from './components/TooltipFormatter';
8
8
  import { PluginManager } from './components/PluginManager';
9
9
  import { DrawingEditor } from './components/DrawingEditor';
10
+ import { DrawingRendererRegistry } from './components/DrawingRendererRegistry';
10
11
  import { EventBus } from './utils/EventBus';
11
12
  import { AxisUtils } from './utils/AxisUtils';
12
13
  import { TableOverlayRenderer } from './components/TableOverlayRenderer';
14
+ import { TableCanvasRenderer } from './components/TableCanvasRenderer';
13
15
 
14
16
  export class QFChart implements ChartContext {
15
17
  private chart: echarts.ECharts;
@@ -28,6 +30,7 @@ export class QFChart implements ChartContext {
28
30
 
29
31
  // Drawing System
30
32
  private drawings: import('./types').DrawingElement[] = [];
33
+ private drawingRenderers: DrawingRendererRegistry = new DrawingRendererRegistry();
31
34
 
32
35
  public coordinateConversion = {
33
36
  pixelToData: (point: { x: number; y: number }) => {
@@ -54,9 +57,11 @@ export class QFChart implements ChartContext {
54
57
  const pGrid = this.chart.convertFromPixel({ gridIndex: i }, [point.x, point.y]);
55
58
 
56
59
  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 };
60
+ // Store in real data indices (subtract padding offset).
61
+ // This makes drawing coordinates independent of lazy padding
62
+ // expansion when _resizePadding() changes dataIndexOffset,
63
+ // stored coordinates stay valid without manual updating.
64
+ return { timeIndex: Math.round(pGrid[0]) - this.dataIndexOffset, value: pGrid[1], paneIndex: i };
60
65
  }
61
66
  }
62
67
  }
@@ -64,8 +69,8 @@ export class QFChart implements ChartContext {
64
69
  },
65
70
  dataToPixel: (point: { timeIndex: number; value: number; paneIndex?: number }) => {
66
71
  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]);
72
+ // Convert real data index back to padded space for ECharts
73
+ const p = this.chart.convertToPixel({ gridIndex: paneIdx }, [point.timeIndex + this.dataIndexOffset, point.value]);
69
74
  if (p) {
70
75
  return { x: p[0], y: p[1] };
71
76
  }
@@ -79,6 +84,12 @@ export class QFChart implements ChartContext {
79
84
  private readonly defaultPadding = 0.0;
80
85
  private padding: number;
81
86
  private dataIndexOffset: number = 0; // Offset for phantom padding data
87
+ private _paddingPoints: number = 0; // Current symmetric padding (empty bars per side)
88
+ private readonly LAZY_MIN_PADDING = 5; // Always have a tiny buffer so edge scroll triggers
89
+ private readonly LAZY_MAX_PADDING = 500; // Hard cap per side
90
+ private readonly LAZY_CHUNK_SIZE = 50; // Bars added per expansion
91
+ private readonly LAZY_EDGE_THRESHOLD = 10; // Bars from edge to trigger
92
+ private _expandScheduled: boolean = false; // Debounce flag
82
93
 
83
94
  // DOM Elements for Layout
84
95
  private rootContainer: HTMLElement;
@@ -89,6 +100,9 @@ export class QFChart implements ChartContext {
89
100
  private chartContainer: HTMLElement;
90
101
  private overlayContainer: HTMLElement;
91
102
  private _lastTables: any[] = [];
103
+ private _tableGraphicIds: string[] = []; // Track canvas table graphic IDs for cleanup
104
+ private _baseGraphics: any[] = []; // Non-table graphic elements (title, watermark, pane labels)
105
+ private _labelTooltipEl: HTMLElement | null = null; // Floating tooltip for label.set_tooltip()
92
106
 
93
107
  // Pane drag-resize state
94
108
  private _lastLayout: (LayoutResult & { overlayYAxisMap: Map<string, number>; separatePaneYAxisOffset: number }) | null = null;
@@ -105,7 +119,7 @@ export class QFChart implements ChartContext {
105
119
  constructor(container: HTMLElement, options: QFChartOptions = {}) {
106
120
  this.rootContainer = container;
107
121
  this.options = {
108
- title: 'Market',
122
+ title: undefined,
109
123
  height: '600px',
110
124
  backgroundColor: '#1e293b',
111
125
  upColor: '#00da3c',
@@ -195,12 +209,15 @@ export class QFChart implements ChartContext {
195
209
  // Overlay container for table rendering (positioned above ECharts canvas)
196
210
  this.chartContainer.style.position = 'relative';
197
211
  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;';
212
+ this.overlayContainer.style.cssText =
213
+ 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:100;overflow:hidden;';
199
214
  this.chartContainer.appendChild(this.overlayContainer);
200
215
 
201
216
  this.pluginManager = new PluginManager(this, this.toolbarContainer);
202
217
  this.drawingEditor = new DrawingEditor(this);
203
218
 
219
+
220
+
204
221
  // Bind global chart/ZRender events to the EventBus
205
222
  this.chart.on('dataZoom', (params: any) => {
206
223
  this.events.emit('chart:dataZoom', params);
@@ -209,22 +226,28 @@ export class QFChart implements ChartContext {
209
226
  const triggerOn = this.options.databox?.triggerOn;
210
227
  const position = this.options.databox?.position;
211
228
  if (triggerOn === 'click' && position === 'floating') {
212
- // Hide tooltip by dispatching a hideTooltip action
213
- this.chart.dispatchAction({
214
- type: 'hideTip',
215
- });
229
+ this.chart.dispatchAction({ type: 'hideTip' });
216
230
  }
231
+
232
+ // Lazy padding: check if user scrolled near an edge
233
+ this._checkEdgeAndExpand();
217
234
  });
218
235
  // @ts-ignore - ECharts event handler type mismatch
219
236
  this.chart.on('finished', (params: any) => this.events.emit('chart:updated', params)); // General chart update
220
237
  // @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); });
238
+ this.chart.getZr().on('mousedown', (params: any) => {
239
+ if (!this._paneDragState) this.events.emit('mouse:down', params);
240
+ });
222
241
  // @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); });
242
+ this.chart.getZr().on('mousemove', (params: any) => {
243
+ if (!this._paneDragState) this.events.emit('mouse:move', params);
244
+ });
224
245
  // @ts-ignore - ECharts ZRender event handler type mismatch
225
246
  this.chart.getZr().on('mouseup', (params: any) => this.events.emit('mouse:up', params));
226
247
  // @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); });
248
+ this.chart.getZr().on('click', (params: any) => {
249
+ if (!this._paneDragState) this.events.emit('mouse:click', params);
250
+ });
228
251
 
229
252
  const zr = this.chart.getZr();
230
253
  const originalSetCursorStyle = zr.setCursorStyle;
@@ -275,9 +298,9 @@ export class QFChart implements ChartContext {
275
298
 
276
299
  // ── Pane border drag-resize ────────────────────────────────
277
300
  private bindPaneResizeEvents(): void {
278
- const MIN_MAIN = 10; // minimum main pane height %
301
+ const MIN_MAIN = 10; // minimum main pane height %
279
302
  const MIN_INDICATOR = 5; // minimum indicator pane height %
280
- const HIT_ZONE = 6; // hit-zone in pixels (±3px from boundary center)
303
+ const HIT_ZONE = 6; // hit-zone in pixels (±3px from boundary center)
281
304
 
282
305
  const zr = this.chart.getZr();
283
306
 
@@ -488,8 +511,8 @@ export class QFChart implements ChartContext {
488
511
  });
489
512
  // Set cursor
490
513
  this.chart.getZr().setCursorStyle('move');
491
- } else if (info.targetName?.startsWith('point')) {
492
- const pointIdx = info.targetName === 'point-start' ? 0 : 1;
514
+ } else if (info.targetName?.startsWith('point-')) {
515
+ const pointIdx = parseInt(info.targetName.split('-')[1]) || 0;
493
516
  this.events.emit('drawing:point:hover', {
494
517
  id: info.drawing.id,
495
518
  pointIndex: pointIdx,
@@ -530,8 +553,8 @@ export class QFChart implements ChartContext {
530
553
 
531
554
  if (info.targetName === 'line') {
532
555
  this.events.emit('drawing:mouseout', { id: info.drawing.id });
533
- } else if (info.targetName?.startsWith('point')) {
534
- const pointIdx = info.targetName === 'point-start' ? 0 : 1;
556
+ } else if (info.targetName?.startsWith('point-')) {
557
+ const pointIdx = parseInt(info.targetName.split('-')[1]) || 0;
535
558
  this.events.emit('drawing:point:mouseout', {
536
559
  id: info.drawing.id,
537
560
  pointIndex: pointIdx,
@@ -554,8 +577,8 @@ export class QFChart implements ChartContext {
554
577
  x,
555
578
  y,
556
579
  });
557
- } else if (info.targetName?.startsWith('point')) {
558
- const pointIdx = info.targetName === 'point-start' ? 0 : 1;
580
+ } else if (info.targetName?.startsWith('point-')) {
581
+ const pointIdx = parseInt(info.targetName.split('-')[1]) || 0;
559
582
  this.events.emit('drawing:point:mousedown', {
560
583
  id: info.drawing.id,
561
584
  pointIndex: pointIdx,
@@ -578,8 +601,8 @@ export class QFChart implements ChartContext {
578
601
 
579
602
  if (info.targetName === 'line') {
580
603
  this.events.emit('drawing:click', { id: info.drawing.id });
581
- } else if (info.targetName?.startsWith('point')) {
582
- const pointIdx = info.targetName === 'point-start' ? 0 : 1;
604
+ } else if (info.targetName?.startsWith('point-')) {
605
+ const pointIdx = parseInt(info.targetName.split('-')[1]) || 0;
583
606
  this.events.emit('drawing:point:click', {
584
607
  id: info.drawing.id,
585
608
  pointIndex: pointIdx,
@@ -598,6 +621,47 @@ export class QFChart implements ChartContext {
598
621
  }
599
622
  }
600
623
  });
624
+
625
+ // --- Label Tooltip ---
626
+ // Create floating tooltip overlay for Pine Script label.set_tooltip()
627
+ this._labelTooltipEl = document.createElement('div');
628
+ this._labelTooltipEl.style.cssText =
629
+ 'position:absolute;display:none;pointer-events:none;z-index:200;' +
630
+ 'background:rgba(30,41,59,0.95);color:#fff;border:1px solid #475569;' +
631
+ 'border-radius:4px;padding:6px 10px;font-size:12px;line-height:1.5;' +
632
+ 'white-space:pre-wrap;max-width:350px;box-shadow:0 2px 8px rgba(0,0,0,0.3);' +
633
+ 'font-family:' +
634
+ (this.options.fontFamily || 'sans-serif') +
635
+ ';';
636
+ this.chartContainer.appendChild(this._labelTooltipEl);
637
+
638
+ // Show tooltip on scatter item hover (labels with tooltip text)
639
+ this.chart.on('mouseover', { seriesType: 'scatter' }, (params: any) => {
640
+ const tooltipText = params.data?._tooltipText;
641
+ if (!tooltipText || !this._labelTooltipEl) return;
642
+
643
+ this._labelTooltipEl.textContent = tooltipText;
644
+ this._labelTooltipEl.style.display = 'block';
645
+
646
+ // Position below the scatter point
647
+ const chartRect = this.chartContainer.getBoundingClientRect();
648
+ const event = params.event?.event;
649
+ if (event) {
650
+ const x = event.clientX - chartRect.left;
651
+ const y = event.clientY - chartRect.top;
652
+ // Show below and slightly left of cursor
653
+ const tipWidth = this._labelTooltipEl.offsetWidth;
654
+ const left = Math.min(x - tipWidth / 2, chartRect.width - tipWidth - 8);
655
+ this._labelTooltipEl.style.left = Math.max(4, left) + 'px';
656
+ this._labelTooltipEl.style.top = y + 18 + 'px';
657
+ }
658
+ });
659
+
660
+ this.chart.on('mouseout', { seriesType: 'scatter' }, () => {
661
+ if (this._labelTooltipEl) {
662
+ this._labelTooltipEl.style.display = 'none';
663
+ }
664
+ });
601
665
  }
602
666
 
603
667
  // --- Plugin System Integration ---
@@ -626,6 +690,64 @@ export class QFChart implements ChartContext {
626
690
  this.pluginManager.register(plugin);
627
691
  }
628
692
 
693
+ public registerDrawingRenderer(renderer: DrawingRenderer): void {
694
+ this.drawingRenderers.register(renderer);
695
+ }
696
+
697
+ public snapToCandle(point: { x: number; y: number }): { x: number; y: number } {
698
+ // Find which pane the point is in
699
+ const dataCoord = this.coordinateConversion.pixelToData(point);
700
+ if (!dataCoord) return point;
701
+
702
+ const paneIndex = dataCoord.paneIndex || 0;
703
+ // Only snap on the main pane (candlestick data)
704
+ if (paneIndex !== 0) return point;
705
+
706
+ // Get the nearest candle by time index
707
+ const realIndex = Math.round(dataCoord.timeIndex);
708
+ if (realIndex < 0 || realIndex >= this.marketData.length) return point;
709
+
710
+ const candle = this.marketData[realIndex];
711
+ if (!candle) return point;
712
+
713
+ // Snap X to the exact candle center
714
+ const snappedX = this.chart.convertToPixel(
715
+ { gridIndex: paneIndex },
716
+ [realIndex + this.dataIndexOffset, candle.close],
717
+ );
718
+ if (!snappedX) return point;
719
+ const snapPxX = snappedX[0];
720
+
721
+ // Find closest OHLC value by Y distance
722
+ const ohlc = [candle.open, candle.high, candle.low, candle.close];
723
+ let bestValue = ohlc[0];
724
+ let bestDist = Infinity;
725
+
726
+ for (const val of ohlc) {
727
+ const px = this.chart.convertToPixel(
728
+ { gridIndex: paneIndex },
729
+ [realIndex + this.dataIndexOffset, val],
730
+ );
731
+ if (px) {
732
+ const dist = Math.abs(px[1] - point.y);
733
+ if (dist < bestDist) {
734
+ bestDist = dist;
735
+ bestValue = val;
736
+ }
737
+ }
738
+ }
739
+
740
+ const snappedY = this.chart.convertToPixel(
741
+ { gridIndex: paneIndex },
742
+ [realIndex + this.dataIndexOffset, bestValue],
743
+ );
744
+
745
+ return {
746
+ x: snapPxX,
747
+ y: snappedY ? snappedY[1] : point.y,
748
+ };
749
+ }
750
+
629
751
  // --- Drawing System ---
630
752
 
631
753
  public addDrawing(drawing: import('./types').DrawingElement): void {
@@ -842,19 +964,20 @@ export class QFChart implements ChartContext {
842
964
  paddingPoints,
843
965
  paddedOHLCVForShapes, // Pass padded OHLCV data
844
966
  layout.overlayYAxisMap, // Pass overlay Y-axis mapping
845
- layout.separatePaneYAxisOffset // Pass Y-axis offset for separate panes
967
+ layout.separatePaneYAxisOffset, // Pass Y-axis offset for separate panes
846
968
  );
847
969
 
848
970
  // Apply barColors to candlestick data
971
+ // TradingView behavior: barcolor() only changes body fill; borders/wicks keep default colors (green/red)
849
972
  const coloredCandlestickData = paddedCandlestickData.map((candle: any, i: number) => {
850
973
  if (barColors[i]) {
974
+ const vals = candle.value || candle;
851
975
  return {
852
- value: candle.value || candle,
976
+ value: vals,
853
977
  itemStyle: {
854
- color: barColors[i],
855
- color0: barColors[i],
856
- borderColor: barColors[i],
857
- borderColor0: barColors[i],
978
+ color: barColors[i], // up-candle body fill
979
+ color0: barColors[i], // down-candle body fill
980
+ // borderColor/borderColor0 intentionally omitted → inherits series default (green/red)
858
981
  },
859
982
  };
860
983
  }
@@ -871,16 +994,7 @@ export class QFChart implements ChartContext {
871
994
  data: coloredCandlestickData,
872
995
  markLine: candlestickSeries.markLine, // Ensure markLine is updated
873
996
  },
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
- }),
997
+ ...indicatorSeries,
884
998
  ],
885
999
  };
886
1000
 
@@ -897,7 +1011,7 @@ export class QFChart implements ChartContext {
897
1011
  tables.forEach((t: any) => {
898
1012
  if (t && !t._deleted) {
899
1013
  // Tag table with its indicator's pane for correct positioning
900
- t._paneIndex = (t.force_overlay) ? 0 : indicator.paneIndex;
1014
+ t._paneIndex = t.force_overlay ? 0 : indicator.paneIndex;
901
1015
  allTables.push(t);
902
1016
  }
903
1017
  });
@@ -916,14 +1030,23 @@ export class QFChart implements ChartContext {
916
1030
  // Stop existing timer
917
1031
  this.stopCountdown();
918
1032
 
919
- if (!this.options.lastPriceLine?.showCountdown || !this.options.interval || this.marketData.length === 0) {
1033
+ if (!this.options.lastPriceLine?.showCountdown || this.marketData.length === 0) {
920
1034
  return;
921
1035
  }
922
1036
 
1037
+ // Auto-detect interval from market data if not explicitly set
1038
+ let interval = this.options.interval;
1039
+ if (!interval && this.marketData.length >= 2) {
1040
+ const last = this.marketData[this.marketData.length - 1];
1041
+ const prev = this.marketData[this.marketData.length - 2];
1042
+ interval = last.time - prev.time;
1043
+ }
1044
+ if (!interval) return;
1045
+
923
1046
  const updateLabel = () => {
924
1047
  if (this.marketData.length === 0) return;
925
1048
  const lastBar = this.marketData[this.marketData.length - 1];
926
- const nextCloseTime = lastBar.time + (this.options.interval || 0);
1049
+ const nextCloseTime = lastBar.time + interval!;
927
1050
  const now = Date.now();
928
1051
  const diff = nextCloseTime - now;
929
1052
 
@@ -974,9 +1097,8 @@ export class QFChart implements ChartContext {
974
1097
  if (this.options.yAxisLabelFormatter) {
975
1098
  priceStr = this.options.yAxisLabelFormatter(price);
976
1099
  } else {
977
- const decimals = this.options.yAxisDecimalPlaces !== undefined
978
- ? this.options.yAxisDecimalPlaces
979
- : AxisUtils.autoDetectDecimals(this.marketData);
1100
+ const decimals =
1101
+ this.options.yAxisDecimalPlaces !== undefined ? this.options.yAxisDecimalPlaces : AxisUtils.autoDetectDecimals(this.marketData);
980
1102
  priceStr = AxisUtils.formatValue(price, decimals);
981
1103
  }
982
1104
 
@@ -989,7 +1111,7 @@ export class QFChart implements ChartContext {
989
1111
  this.chart.setOption({
990
1112
  series: [
991
1113
  {
992
- name: this.options.title || 'Market',
1114
+ id: '__candlestick__',
993
1115
  markLine: {
994
1116
  data: [
995
1117
  {
@@ -1030,10 +1152,10 @@ export class QFChart implements ChartContext {
1030
1152
  height?: number;
1031
1153
  titleColor?: string;
1032
1154
  controls?: { collapse?: boolean; maximize?: boolean };
1033
- } = {}
1155
+ } = {},
1034
1156
  ): Indicator {
1035
1157
  // Handle backward compatibility: prefer 'overlay' over 'isOverlay'
1036
- const isOverlay = options.overlay !== undefined ? options.overlay : options.isOverlay ?? false;
1158
+ const isOverlay = options.overlay !== undefined ? options.overlay : (options.isOverlay ?? false);
1037
1159
  let paneIndex = 0;
1038
1160
  if (!isOverlay) {
1039
1161
  // Find the next available pane index
@@ -1110,11 +1232,42 @@ export class QFChart implements ChartContext {
1110
1232
  this._renderTableOverlays();
1111
1233
  }
1112
1234
 
1113
- private _renderTableOverlays(): void {
1235
+ /**
1236
+ * Build table canvas graphic elements from the current _lastTables.
1237
+ * Must be called AFTER setOption so grid rects are available from ECharts.
1238
+ * Returns an array of ECharts graphic elements.
1239
+ */
1240
+ private _buildTableGraphics(): any[] {
1114
1241
  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);
1242
+ const getGridRect = (paneIndex: number) => model.getComponent('grid', paneIndex)?.coordinateSystem?.getRect();
1243
+ const elements = TableCanvasRenderer.buildGraphicElements(this._lastTables, getGridRect);
1244
+ // Assign stable IDs for future merge/replace
1245
+ this._tableGraphicIds = [];
1246
+ for (let i = 0; i < elements.length; i++) {
1247
+ const id = `__qf_table_${i}`;
1248
+ elements[i].id = id;
1249
+ this._tableGraphicIds.push(id);
1250
+ }
1251
+ return elements;
1252
+ }
1253
+
1254
+ /**
1255
+ * Render table overlays after a non-replacing setOption (updateData, resize).
1256
+ * Uses replaceMerge to cleanly replace all graphic elements without disrupting
1257
+ * other interactive components (dataZoom, tooltip, etc.).
1258
+ */
1259
+ private _renderTableOverlays(): void {
1260
+ // Build new table graphics
1261
+ const tableGraphics = this._buildTableGraphics();
1262
+
1263
+ // Combine base graphics (title, watermark) + table graphics and replace all at once.
1264
+ // Using replaceMerge: ['graphic'] replaces ONLY the graphic component,
1265
+ // leaving dataZoom, tooltip, series etc. untouched.
1266
+ const allGraphics = [...this._baseGraphics, ...tableGraphics];
1267
+ this.chart.setOption({ graphic: allGraphics }, { replaceMerge: ['graphic'] } as any);
1268
+
1269
+ // Clear DOM overlays (legacy) — keep overlay container empty
1270
+ TableOverlayRenderer.clearAll(this.overlayContainer);
1118
1271
  }
1119
1272
 
1120
1273
  public destroy(): void {
@@ -1133,10 +1286,198 @@ export class QFChart implements ChartContext {
1133
1286
  this.timeToIndex.set(k.time, index);
1134
1287
  });
1135
1288
 
1136
- // Update dataIndexOffset whenever data changes
1289
+ // Calculate initial padding from user-configured ratio
1290
+ const dataLength = this.marketData.length;
1291
+ const initialPadding = Math.ceil(dataLength * this.padding);
1292
+
1293
+ // _paddingPoints can only grow (lazy expansion), never shrink below initial or minimum
1294
+ this._paddingPoints = Math.max(this._paddingPoints, initialPadding, this.LAZY_MIN_PADDING);
1295
+ this.dataIndexOffset = this._paddingPoints;
1296
+ }
1297
+
1298
+ /**
1299
+ * Expand symmetric padding to the given number of points per side.
1300
+ * No-op if newPaddingPoints <= current. Performs a full render() and
1301
+ * restores the viewport position so there is no visual jump.
1302
+ */
1303
+ public expandPadding(newPaddingPoints: number): void {
1304
+ this._resizePadding(newPaddingPoints);
1305
+ }
1306
+
1307
+ /**
1308
+ * Resize symmetric padding to the given number of points per side.
1309
+ * Works for both growing and shrinking. Clamps to [min, max].
1310
+ * Uses merge-mode setOption to preserve drag/interaction state.
1311
+ */
1312
+ private _resizePadding(newPaddingPoints: number): void {
1313
+ // Clamp to bounds
1314
+ const initialPadding = Math.ceil(this.marketData.length * this.padding);
1315
+ newPaddingPoints = Math.max(newPaddingPoints, initialPadding, this.LAZY_MIN_PADDING);
1316
+ newPaddingPoints = Math.min(newPaddingPoints, this.LAZY_MAX_PADDING);
1317
+ if (newPaddingPoints === this._paddingPoints) return;
1318
+
1319
+ // 1. Capture current viewport as absolute bar indices
1320
+ const oldPadding = this._paddingPoints;
1321
+ const oldTotal = this.marketData.length + 2 * oldPadding;
1322
+ const currentOption = this.chart.getOption() as any;
1323
+ const zoomComp = currentOption?.dataZoom?.find((dz: any) => dz.type === 'slider' || dz.type === 'inside');
1324
+ const oldStartIdx = zoomComp ? (zoomComp.start / 100) * oldTotal : 0;
1325
+ const oldEndIdx = zoomComp ? (zoomComp.end / 100) * oldTotal : oldTotal;
1326
+
1327
+ // 2. Update padding state (delta can be positive or negative)
1328
+ const delta = newPaddingPoints - oldPadding;
1329
+ this._paddingPoints = newPaddingPoints;
1330
+ this.dataIndexOffset = this._paddingPoints;
1331
+ const paddingPoints = this._paddingPoints;
1332
+
1333
+ // 3. Rebuild all data arrays with new padding
1334
+ const emptyCandle = { value: [NaN, NaN, NaN, NaN], itemStyle: { opacity: 0 } };
1335
+ const candlestickSeries = SeriesBuilder.buildCandlestickSeries(this.marketData, this.options);
1336
+ const paddedCandlestickData = [
1337
+ ...Array(paddingPoints).fill(emptyCandle),
1338
+ ...candlestickSeries.data,
1339
+ ...Array(paddingPoints).fill(emptyCandle),
1340
+ ];
1341
+ const categoryData = [
1342
+ ...Array(paddingPoints).fill(''),
1343
+ ...this.marketData.map((k) => new Date(k.time).toLocaleString()),
1344
+ ...Array(paddingPoints).fill(''),
1345
+ ];
1346
+ const paddedOHLCVForShapes = [...Array(paddingPoints).fill(null), ...this.marketData, ...Array(paddingPoints).fill(null)];
1347
+
1348
+ // Rebuild indicator series with new offset
1349
+ const layout = LayoutManager.calculate(
1350
+ this.chart.getHeight(),
1351
+ this.indicators,
1352
+ this.options,
1353
+ this.isMainCollapsed,
1354
+ this.maximizedPaneId,
1355
+ this.marketData,
1356
+ this._mainHeightOverride ?? undefined,
1357
+ );
1358
+ const { series: indicatorSeries, barColors } = SeriesBuilder.buildIndicatorSeries(
1359
+ this.indicators,
1360
+ this.timeToIndex,
1361
+ layout.paneLayout,
1362
+ categoryData.length,
1363
+ paddingPoints,
1364
+ paddedOHLCVForShapes,
1365
+ layout.overlayYAxisMap,
1366
+ layout.separatePaneYAxisOffset,
1367
+ );
1368
+
1369
+ // Apply barColors (TradingView: barcolor() only changes body fill, borders/wicks stay default)
1370
+ const coloredCandlestickData = paddedCandlestickData.map((candle: any, i: number) => {
1371
+ if (barColors[i]) {
1372
+ const vals = candle.value || candle;
1373
+ return {
1374
+ value: vals,
1375
+ itemStyle: {
1376
+ color: barColors[i],
1377
+ color0: barColors[i],
1378
+ },
1379
+ };
1380
+ }
1381
+ return candle;
1382
+ });
1383
+
1384
+ // 4. Calculate corrected zoom for new total length
1385
+ const newTotal = this.marketData.length + 2 * newPaddingPoints;
1386
+ const newStart = Math.max(0, ((oldStartIdx + delta) / newTotal) * 100);
1387
+ const newEnd = Math.min(100, ((oldEndIdx + delta) / newTotal) * 100);
1388
+
1389
+ // 5. Rebuild drawing series data with new offset so ECharts
1390
+ // viewport culling uses correct padded indices after expansion.
1391
+ const drawingSeriesUpdates: any[] = [];
1392
+ const drawingsByPane = new Map<number, import('./types').DrawingElement[]>();
1393
+ this.drawings.forEach((d) => {
1394
+ const paneIdx = d.paneIndex || 0;
1395
+ if (!drawingsByPane.has(paneIdx)) drawingsByPane.set(paneIdx, []);
1396
+ drawingsByPane.get(paneIdx)!.push(d);
1397
+ });
1398
+ drawingsByPane.forEach((paneDrawings) => {
1399
+ drawingSeriesUpdates.push({
1400
+ data: paneDrawings.map((d) => [
1401
+ d.points[0].timeIndex + this.dataIndexOffset,
1402
+ d.points[0].value,
1403
+ d.points[1].timeIndex + this.dataIndexOffset,
1404
+ d.points[1].value,
1405
+ ]),
1406
+ });
1407
+ });
1408
+
1409
+ // 6. Merge update — preserves drag/interaction state
1410
+ const updateOption: any = {
1411
+ xAxis: currentOption.xAxis.map(() => ({ data: categoryData })),
1412
+ dataZoom: [
1413
+ { start: newStart, end: newEnd },
1414
+ { start: newStart, end: newEnd },
1415
+ ],
1416
+ series: [
1417
+ { data: coloredCandlestickData, markLine: candlestickSeries.markLine },
1418
+ ...indicatorSeries.map((s) => {
1419
+ const update: any = { data: s.data };
1420
+ if (s.renderItem) update.renderItem = s.renderItem;
1421
+ return update;
1422
+ }),
1423
+ ...drawingSeriesUpdates,
1424
+ ],
1425
+ };
1426
+ this.chart.setOption(updateOption, { notMerge: false });
1427
+ }
1428
+
1429
+ /**
1430
+ * Check if user scrolled near an edge (expand) or away from edges (contract).
1431
+ * Uses requestAnimationFrame to avoid cascading re-renders inside
1432
+ * the ECharts dataZoom event callback.
1433
+ */
1434
+ private _checkEdgeAndExpand(): void {
1435
+ if (this._expandScheduled) return;
1436
+
1437
+ const zoomComp = (this.chart.getOption() as any)?.dataZoom?.find((dz: any) => dz.type === 'slider' || dz.type === 'inside');
1438
+ if (!zoomComp) return;
1439
+
1440
+ const paddingPoints = this._paddingPoints;
1137
1441
  const dataLength = this.marketData.length;
1138
- const paddingPoints = Math.ceil(dataLength * this.padding);
1139
- this.dataIndexOffset = paddingPoints;
1442
+ const totalLength = dataLength + 2 * paddingPoints;
1443
+ const startIdx = Math.round((zoomComp.start / 100) * totalLength);
1444
+ const endIdx = Math.round((zoomComp.end / 100) * totalLength);
1445
+
1446
+ // Count visible real candles (overlap between viewport and data range)
1447
+ const dataStart = paddingPoints;
1448
+ const dataEnd = paddingPoints + dataLength - 1;
1449
+ const visibleCandles = Math.max(0, Math.min(endIdx, dataEnd) - Math.max(startIdx, dataStart) + 1);
1450
+
1451
+ const nearLeftEdge = startIdx < this.LAZY_EDGE_THRESHOLD;
1452
+ const nearRightEdge = endIdx > totalLength - this.LAZY_EDGE_THRESHOLD;
1453
+
1454
+ // Don't expand when zoomed in very tight (fewer than 3 visible candles)
1455
+ if ((nearLeftEdge || nearRightEdge) && paddingPoints < this.LAZY_MAX_PADDING && visibleCandles >= 3) {
1456
+ this._expandScheduled = true;
1457
+ requestAnimationFrame(() => {
1458
+ this._expandScheduled = false;
1459
+ this._resizePadding(paddingPoints + this.LAZY_CHUNK_SIZE);
1460
+ });
1461
+ return;
1462
+ }
1463
+
1464
+ // Contract if far from both edges and padding is larger than needed
1465
+ // Calculate how many padding bars are visible/near-visible on each side
1466
+ const leftPadUsed = Math.max(0, paddingPoints - startIdx);
1467
+ const rightPadUsed = Math.max(0, endIdx - (paddingPoints + dataLength - 1));
1468
+ const neededPadding = Math.max(
1469
+ leftPadUsed + this.LAZY_CHUNK_SIZE, // keep one chunk of buffer
1470
+ rightPadUsed + this.LAZY_CHUNK_SIZE,
1471
+ );
1472
+
1473
+ // Only contract if we have at least one full chunk of excess
1474
+ if (paddingPoints > neededPadding + this.LAZY_CHUNK_SIZE) {
1475
+ this._expandScheduled = true;
1476
+ requestAnimationFrame(() => {
1477
+ this._expandScheduled = false;
1478
+ this._resizePadding(neededPadding);
1479
+ });
1480
+ }
1140
1481
  }
1141
1482
 
1142
1483
  private render(): void {
@@ -1258,19 +1599,18 @@ export class QFChart implements ChartContext {
1258
1599
  paddingPoints,
1259
1600
  paddedOHLCVForShapes, // Pass padded OHLCV
1260
1601
  layout.overlayYAxisMap, // Pass overlay Y-axis mapping
1261
- layout.separatePaneYAxisOffset // Pass Y-axis offset for separate panes
1602
+ layout.separatePaneYAxisOffset, // Pass Y-axis offset for separate panes
1262
1603
  );
1263
1604
 
1264
- // Apply barColors to candlestick data
1605
+ // Apply barColors (TradingView: barcolor() only changes body fill, borders/wicks stay default)
1265
1606
  candlestickSeries.data = candlestickSeries.data.map((candle: any, i: number) => {
1266
1607
  if (barColors[i]) {
1608
+ const vals = candle.value || candle;
1267
1609
  return {
1268
- value: candle.value || candle,
1610
+ value: vals,
1269
1611
  itemStyle: {
1270
1612
  color: barColors[i],
1271
1613
  color0: barColors[i],
1272
- borderColor: barColors[i],
1273
- borderColor0: barColors[i],
1274
1614
  },
1275
1615
  };
1276
1616
  }
@@ -1278,7 +1618,20 @@ export class QFChart implements ChartContext {
1278
1618
  });
1279
1619
 
1280
1620
  // 3. Build Graphics
1281
- const graphic = GraphicBuilder.build(layout, this.options, this.toggleIndicator.bind(this), this.isMainCollapsed, this.maximizedPaneId);
1621
+ const overlayIndicators: { id: string; titleColor?: string }[] = [];
1622
+ this.indicators.forEach((ind, id) => {
1623
+ if (ind.paneIndex === 0) {
1624
+ overlayIndicators.push({ id, titleColor: ind.titleColor });
1625
+ }
1626
+ });
1627
+ const graphic = GraphicBuilder.build(
1628
+ layout,
1629
+ this.options,
1630
+ this.toggleIndicator.bind(this),
1631
+ this.isMainCollapsed,
1632
+ this.maximizedPaneId,
1633
+ overlayIndicators,
1634
+ );
1282
1635
 
1283
1636
  // 4. Build Drawings Series (One Custom Series per Pane used)
1284
1637
  const drawingsByPane = new Map<number, import('./types').DrawingElement[]>();
@@ -1302,254 +1655,34 @@ export class QFChart implements ChartContext {
1302
1655
  const drawing = drawings[params.dataIndex];
1303
1656
  if (!drawing) return;
1304
1657
 
1305
- const start = drawing.points[0];
1306
- const end = drawing.points[1];
1307
-
1308
- if (!start || !end) return;
1309
-
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]);
1313
-
1314
- const isSelected = drawing.id === this.selectedDrawingId;
1315
-
1316
- if (drawing.type === 'line') {
1317
- return {
1318
- type: 'group',
1319
- children: [
1320
- {
1321
- type: 'line',
1322
- name: 'line',
1323
- shape: {
1324
- x1: p1[0],
1325
- y1: p1[1],
1326
- x2: p2[0],
1327
- y2: p2[1],
1328
- },
1329
- style: {
1330
- stroke: drawing.style?.color || '#3b82f6',
1331
- lineWidth: drawing.style?.lineWidth || 2,
1332
- },
1333
- },
1334
- {
1335
- type: 'circle',
1336
- name: 'point-start',
1337
- shape: { cx: p1[0], cy: p1[1], r: 4 },
1338
- style: {
1339
- fill: '#fff',
1340
- stroke: drawing.style?.color || '#3b82f6',
1341
- lineWidth: 1,
1342
- opacity: isSelected ? 1 : 0, // Show if selected
1343
- },
1344
- },
1345
- {
1346
- type: 'circle',
1347
- name: 'point-end',
1348
- shape: { cx: p2[0], cy: p2[1], r: 4 },
1349
- style: {
1350
- fill: '#fff',
1351
- stroke: drawing.style?.color || '#3b82f6',
1352
- lineWidth: 1,
1353
- opacity: isSelected ? 1 : 0, // Show if selected
1354
- },
1355
- },
1356
- ],
1357
- };
1358
- } else if (drawing.type === 'fibonacci') {
1359
- const x1 = p1[0];
1360
- const y1 = p1[1];
1361
- const x2 = p2[0];
1362
- const y2 = p2[1];
1363
-
1364
- const startX = Math.min(x1, x2);
1365
- const endX = Math.max(x1, x2);
1366
- const width = endX - startX;
1367
- const diffY = y2 - y1;
1368
-
1369
- const levels = [0, 0.236, 0.382, 0.5, 0.618, 0.786, 1];
1370
- const colors = ['#787b86', '#f44336', '#ff9800', '#4caf50', '#2196f3', '#00bcd4', '#787b86'];
1371
-
1372
- const children: any[] = [];
1373
-
1374
- // 1. Diagonal Line
1375
- children.push({
1376
- type: 'line',
1377
- name: 'line', // Use 'line' name to enable dragging logic in DrawingEditor
1378
- shape: { x1, y1, x2, y2 },
1379
- style: {
1380
- stroke: '#999',
1381
- lineWidth: 1,
1382
- lineDash: [4, 4],
1383
- },
1384
- });
1385
-
1386
- // 2. Control Points (invisible by default)
1387
- children.push({
1388
- type: 'circle',
1389
- name: 'point-start',
1390
- shape: { cx: x1, cy: y1, r: 4 },
1391
- style: {
1392
- fill: '#fff',
1393
- stroke: drawing.style?.color || '#3b82f6',
1394
- lineWidth: 1,
1395
- opacity: isSelected ? 1 : 0,
1396
- },
1397
- z: 100, // Ensure on top
1398
- });
1399
- children.push({
1400
- type: 'circle',
1401
- name: 'point-end',
1402
- shape: { cx: x2, cy: y2, r: 4 },
1403
- style: {
1404
- fill: '#fff',
1405
- stroke: drawing.style?.color || '#3b82f6',
1406
- lineWidth: 1,
1407
- opacity: isSelected ? 1 : 0,
1408
- },
1409
- z: 100,
1410
- });
1411
-
1412
- // 3. Levels and Backgrounds
1413
- levels.forEach((level, index) => {
1414
- const levelY = y2 - diffY * level;
1415
- const color = colors[index % colors.length];
1416
-
1417
- // Horizontal Line
1418
- children.push({
1419
- type: 'line',
1420
- name: 'fib-line', // distinct name, maybe we don't want to drag by clicking these lines? or yes? 'line' triggers drag. 'fib-line' won't unless we update logic.
1421
- // The user asked for "fib levels between start and end".
1422
- shape: { x1: startX, y1: levelY, x2: endX, y2: levelY },
1423
- style: { stroke: color, lineWidth: 1 },
1424
- silent: true, // Make internal lines silent so clicks pass to background/diagonal?
1425
- });
1426
-
1427
- const startVal = drawing.points[0].value;
1428
- const endVal = drawing.points[1].value;
1429
- const valDiff = endVal - startVal;
1430
- const price = endVal - valDiff * level;
1431
-
1432
- children.push({
1433
- type: 'text',
1434
- style: {
1435
- text: `${level} (${price.toFixed(2)})`,
1436
- x: startX + 5,
1437
- y: levelY - 10,
1438
- fill: color,
1439
- fontSize: 10,
1440
- },
1441
- silent: true,
1442
- });
1443
-
1444
- // Background
1445
- if (index < levels.length - 1) {
1446
- const nextLevel = levels[index + 1];
1447
- const nextY = y2 - diffY * nextLevel;
1448
- const rectH = Math.abs(nextY - levelY);
1449
- const rectY = Math.min(levelY, nextY);
1450
-
1451
- children.push({
1452
- type: 'rect',
1453
- shape: { x: startX, y: rectY, width, height: rectH },
1454
- style: {
1455
- fill: colors[(index + 1) % colors.length],
1456
- opacity: 0.1,
1457
- },
1458
- silent: true, // Let clicks pass through?
1459
- });
1460
- }
1461
- });
1658
+ const renderer = this.drawingRenderers.get(drawing.type);
1659
+ if (!renderer) return;
1462
1660
 
1463
- const backgrounds: any[] = [];
1464
- const linesAndText: any[] = [];
1465
-
1466
- levels.forEach((level, index) => {
1467
- const levelY = y2 - diffY * level;
1468
- const color = colors[index % colors.length];
1469
-
1470
- linesAndText.push({
1471
- type: 'line',
1472
- shape: { x1: startX, y1: levelY, x2: endX, y2: levelY },
1473
- style: { stroke: color, lineWidth: 1 },
1474
- silent: true,
1475
- });
1476
-
1477
- const startVal = drawing.points[0].value;
1478
- const endVal = drawing.points[1].value;
1479
- const valDiff = endVal - startVal;
1480
- const price = endVal - valDiff * level;
1481
-
1482
- linesAndText.push({
1483
- type: 'text',
1484
- style: {
1485
- text: `${level} (${price.toFixed(2)})`,
1486
- x: startX + 5,
1487
- y: levelY - 10,
1488
- fill: color,
1489
- fontSize: 10,
1490
- },
1491
- silent: true,
1492
- });
1493
-
1494
- if (index < levels.length - 1) {
1495
- const nextLevel = levels[index + 1];
1496
- const nextY = y2 - diffY * nextLevel;
1497
- const rectH = Math.abs(nextY - levelY);
1498
- const rectY = Math.min(levelY, nextY);
1499
-
1500
- backgrounds.push({
1501
- type: 'rect',
1502
- name: 'line', // Enable dragging by clicking background!
1503
- shape: { x: startX, y: rectY, width, height: rectH },
1504
- style: {
1505
- fill: colors[(index + 1) % colors.length],
1506
- opacity: 0.1,
1507
- },
1508
- });
1509
- }
1510
- });
1661
+ const drawingOffset = this.dataIndexOffset;
1662
+ const pixelPoints = drawing.points.map(
1663
+ (p) => api.coord([p.timeIndex + drawingOffset, p.value]) as [number, number],
1664
+ );
1511
1665
 
1512
- return {
1513
- type: 'group',
1514
- children: [
1515
- ...backgrounds,
1516
- ...linesAndText,
1517
- {
1518
- type: 'line',
1519
- name: 'line',
1520
- shape: { x1, y1, x2, y2 },
1521
- style: { stroke: '#999', lineWidth: 1, lineDash: [4, 4] },
1522
- },
1523
- {
1524
- type: 'circle',
1525
- name: 'point-start',
1526
- shape: { cx: x1, cy: y1, r: 4 },
1527
- style: {
1528
- fill: '#fff',
1529
- stroke: drawing.style?.color || '#3b82f6',
1530
- lineWidth: 1,
1531
- opacity: isSelected ? 1 : 0,
1532
- },
1533
- z: 100,
1534
- },
1535
- {
1536
- type: 'circle',
1537
- name: 'point-end',
1538
- shape: { cx: x2, cy: y2, r: 4 },
1539
- style: {
1540
- fill: '#fff',
1541
- stroke: drawing.style?.color || '#3b82f6',
1542
- lineWidth: 1,
1543
- opacity: isSelected ? 1 : 0,
1544
- },
1545
- z: 100,
1546
- },
1547
- ],
1548
- };
1549
- }
1666
+ return renderer.render({
1667
+ drawing,
1668
+ pixelPoints,
1669
+ isSelected: drawing.id === this.selectedDrawingId,
1670
+ api,
1671
+ });
1550
1672
  },
1551
- data: drawings.map((d) => [d.points[0].timeIndex, d.points[0].value, d.points[1].timeIndex, d.points[1].value]),
1552
- encode: { x: [0, 2], y: [1, 3] },
1673
+ data: drawings.map((d) => {
1674
+ const row: number[] = [];
1675
+ d.points.forEach((p) => {
1676
+ row.push(p.timeIndex + this.dataIndexOffset, p.value);
1677
+ });
1678
+ return row;
1679
+ }),
1680
+ encode: (() => {
1681
+ const maxPoints = drawings.reduce((max, d) => Math.max(max, d.points.length), 0);
1682
+ const xDims = Array.from({ length: maxPoints }, (_, i) => i * 2);
1683
+ const yDims = Array.from({ length: maxPoints }, (_, i) => i * 2 + 1);
1684
+ return { x: xDims, y: yDims };
1685
+ })(),
1553
1686
  z: 100,
1554
1687
  silent: false,
1555
1688
  });
@@ -1587,7 +1720,7 @@ export class QFChart implements ChartContext {
1587
1720
  tables.forEach((t: any) => {
1588
1721
  if (t && !t._deleted) {
1589
1722
  // Tag table with its indicator's pane for correct positioning
1590
- t._paneIndex = (t.force_overlay) ? 0 : indicator.paneIndex;
1723
+ t._paneIndex = t.force_overlay ? 0 : indicator.paneIndex;
1591
1724
  allTables.push(t);
1592
1725
  }
1593
1726
  });
@@ -1641,8 +1774,24 @@ export class QFChart implements ChartContext {
1641
1774
 
1642
1775
  this.chart.setOption(option, true); // true = not merge, replace.
1643
1776
 
1644
- // Render table overlays AFTER setOption so we can query the computed grid rect
1777
+ // Store base graphics (title, watermark, pane labels) for later re-use
1778
+ // in _renderTableOverlays so we can do a clean replaceMerge.
1779
+ this._baseGraphics = graphic;
1780
+
1781
+ // Render table graphics AFTER setOption so we can query the computed grid rects.
1782
+ // Uses replaceMerge to cleanly set all graphics without disrupting interactive components.
1645
1783
  this._lastTables = allTables;
1646
- this._renderTableOverlays();
1784
+ if (allTables.length > 0) {
1785
+ const tableGraphics = this._buildTableGraphics();
1786
+ if (tableGraphics.length > 0) {
1787
+ const allGraphics = [...graphic, ...tableGraphics];
1788
+ this.chart.setOption({ graphic: allGraphics }, { replaceMerge: ['graphic'] } as any);
1789
+ }
1790
+ } else {
1791
+ this._tableGraphicIds = [];
1792
+ }
1793
+
1794
+ // Clear DOM overlays (legacy)
1795
+ TableOverlayRenderer.clearAll(this.overlayContainer);
1647
1796
  }
1648
1797
  }