@qfo/qfchart 0.6.0 → 0.6.2

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
@@ -20,6 +20,7 @@ export class QFChart implements ChartContext {
20
20
  public events: EventBus = new EventBus();
21
21
  private isMainCollapsed: boolean = false;
22
22
  private maximizedPaneId: string | null = null;
23
+ private countdownInterval: any = null;
23
24
 
24
25
  private selectedDrawingId: string | null = null; // Track selected drawing
25
26
 
@@ -178,7 +179,19 @@ export class QFChart implements ChartContext {
178
179
  this.drawingEditor = new DrawingEditor(this);
179
180
 
180
181
  // Bind global chart/ZRender events to the EventBus
181
- this.chart.on('dataZoom', (params: any) => this.events.emit('chart:dataZoom', params));
182
+ this.chart.on('dataZoom', (params: any) => {
183
+ this.events.emit('chart:dataZoom', params);
184
+
185
+ // Auto-hide tooltip when dragging chart if triggerOn is 'click' and position is 'floating'
186
+ const triggerOn = this.options.databox?.triggerOn;
187
+ const position = this.options.databox?.position;
188
+ if (triggerOn === 'click' && position === 'floating') {
189
+ // Hide tooltip by dispatching a hideTooltip action
190
+ this.chart.dispatchAction({
191
+ type: 'hideTip'
192
+ });
193
+ }
194
+ });
182
195
  // @ts-ignore - ECharts event handler type mismatch
183
196
  this.chart.on('finished', (params: any) => this.events.emit('chart:updated', params)); // General chart update
184
197
  // @ts-ignore - ECharts ZRender event handler type mismatch
@@ -622,9 +635,13 @@ export class QFChart implements ChartContext {
622
635
  const paddingPoints = this.dataIndexOffset;
623
636
 
624
637
  // Build candlestick data with padding
625
- const candlestickData = this.marketData.map((d) => [d.open, d.close, d.low, d.high]);
638
+ const candlestickSeries = SeriesBuilder.buildCandlestickSeries(this.marketData, this.options);
626
639
  const emptyCandle = { value: [NaN, NaN, NaN, NaN], itemStyle: { opacity: 0 } };
627
- const paddedCandlestickData = [...Array(paddingPoints).fill(emptyCandle), ...candlestickData, ...Array(paddingPoints).fill(emptyCandle)];
640
+ const paddedCandlestickData = [
641
+ ...Array(paddingPoints).fill(emptyCandle),
642
+ ...candlestickSeries.data,
643
+ ...Array(paddingPoints).fill(emptyCandle),
644
+ ];
628
645
 
629
646
  // Build category data with padding
630
647
  const categoryData = [
@@ -635,7 +652,14 @@ export class QFChart implements ChartContext {
635
652
 
636
653
  // Build indicator series data
637
654
  const currentOption = this.chart.getOption() as any;
638
- const layout = LayoutManager.calculate(this.chart.getHeight(), this.indicators, this.options, this.isMainCollapsed, this.maximizedPaneId);
655
+ const layout = LayoutManager.calculate(
656
+ this.chart.getHeight(),
657
+ this.indicators,
658
+ this.options,
659
+ this.isMainCollapsed,
660
+ this.maximizedPaneId,
661
+ this.marketData
662
+ );
639
663
 
640
664
  // Pass full padded candlestick data for shape positioning
641
665
  // But SeriesBuilder expects 'OHLCV[]', while paddedCandlestickData is array of arrays [open,close,low,high]
@@ -655,7 +679,9 @@ export class QFChart implements ChartContext {
655
679
  layout.paneLayout,
656
680
  categoryData.length,
657
681
  paddingPoints,
658
- paddedOHLCVForShapes // Pass padded OHLCV data
682
+ paddedOHLCVForShapes, // Pass padded OHLCV data
683
+ layout.overlayYAxisMap, // Pass overlay Y-axis mapping
684
+ layout.separatePaneYAxisOffset // Pass Y-axis offset for separate panes
659
685
  );
660
686
 
661
687
  // Apply barColors to candlestick data
@@ -682,28 +708,148 @@ export class QFChart implements ChartContext {
682
708
  series: [
683
709
  {
684
710
  data: coloredCandlestickData,
711
+ markLine: candlestickSeries.markLine, // Ensure markLine is updated
685
712
  },
686
- ...indicatorSeries.map((s) => ({
687
- data: s.data,
688
- })),
713
+ ...indicatorSeries.map((s) => {
714
+ const update: any = { data: s.data };
715
+ // If the series has a renderItem function (custom series like background),
716
+ // we MUST update it because it likely closes over variables (colorArray)
717
+ // from the SeriesBuilder scope which have been recreated.
718
+ if (s.renderItem) {
719
+ update.renderItem = s.renderItem;
720
+ }
721
+ return update;
722
+ }),
689
723
  ],
690
724
  };
691
725
 
692
726
  // Merge the update (don't replace entire config)
693
727
  this.chart.setOption(updateOption, { notMerge: false });
728
+
729
+ // Update countdown if needed
730
+ this.startCountdown();
731
+ }
732
+
733
+ private startCountdown() {
734
+ // Stop existing timer
735
+ this.stopCountdown();
736
+
737
+ if (!this.options.lastPriceLine?.showCountdown || !this.options.interval || this.marketData.length === 0) {
738
+ return;
739
+ }
740
+
741
+ const updateLabel = () => {
742
+ if (this.marketData.length === 0) return;
743
+ const lastBar = this.marketData[this.marketData.length - 1];
744
+ const nextCloseTime = lastBar.time + (this.options.interval || 0);
745
+ const now = Date.now();
746
+ const diff = nextCloseTime - now;
747
+
748
+ if (diff <= 0) {
749
+ // Timer expired (bar closed), maybe wait for next update
750
+ // Or show 00:00:00
751
+ return;
752
+ }
753
+
754
+ // Format time
755
+ const absDiff = Math.abs(diff);
756
+ const hours = Math.floor(absDiff / 3600000);
757
+ const minutes = Math.floor((absDiff % 3600000) / 60000);
758
+ const seconds = Math.floor((absDiff % 60000) / 1000);
759
+
760
+ const timeString = `${hours > 0 ? hours.toString().padStart(2, '0') + ':' : ''}${minutes.toString().padStart(2, '0')}:${seconds
761
+ .toString()
762
+ .padStart(2, '0')}`;
763
+
764
+ // Update markLine label
765
+ // We need to find the candlestick series index (usually 0)
766
+ // But we can update by name if unique, or by index. SeriesBuilder sets name to options.title or 'Market'
767
+ // Safest is to modify the option directly for series index 0 (if that's where candle is)
768
+ // Or better, check current option
769
+ const currentOption = this.chart.getOption() as any;
770
+ if (!currentOption || !currentOption.series) return;
771
+
772
+ // Find candlestick series (type 'candlestick')
773
+ const candleSeriesIndex = currentOption.series.findIndex((s: any) => s.type === 'candlestick');
774
+ if (candleSeriesIndex === -1) return;
775
+
776
+ const candleSeries = currentOption.series[candleSeriesIndex];
777
+ if (!candleSeries.markLine || !candleSeries.markLine.data || !candleSeries.markLine.data[0]) return;
778
+
779
+ const markLineData = candleSeries.markLine.data[0];
780
+ const currentFormatter = markLineData.label.formatter;
781
+
782
+ // We need to preserve the price formatting logic.
783
+ // But formatter is a function in the option we passed, but ECharts might have stored it?
784
+ // ECharts getOption() returns the merged option. Functions are preserved.
785
+ // We can wrap the formatter or just use the price value.
786
+ // markLineData.yAxis is the price.
787
+
788
+ const price = markLineData.yAxis;
789
+ let priceStr = '';
790
+
791
+ // Re-use formatting logic from options if possible, or simple fix
792
+ if (this.options.yAxisLabelFormatter) {
793
+ priceStr = this.options.yAxisLabelFormatter(price);
794
+ } else {
795
+ const decimals = this.options.yAxisDecimalPlaces !== undefined ? this.options.yAxisDecimalPlaces : 2;
796
+ priceStr = typeof price === 'number' ? price.toFixed(decimals) : price;
797
+ }
798
+
799
+ const labelText = `${priceStr}\n${timeString}`;
800
+
801
+ // Reconstruct the markLine data to preserve styles (lineStyle, symbol, etc.)
802
+ // We spread markLineData to keep everything (including lineStyle which defines color),
803
+ // then overwrite the label to update the formatter/text.
804
+
805
+ this.chart.setOption({
806
+ series: [
807
+ {
808
+ name: this.options.title || 'Market',
809
+ markLine: {
810
+ data: [
811
+ {
812
+ ...markLineData, // Preserve lineStyle (color), symbol, yAxis, etc.
813
+ label: {
814
+ ...markLineData.label, // Preserve existing label styles including backgroundColor
815
+ formatter: labelText, // Update only the text
816
+ },
817
+ },
818
+ ],
819
+ },
820
+ },
821
+ ],
822
+ });
823
+ };
824
+
825
+ // Run immediately
826
+ updateLabel();
827
+
828
+ // Start interval
829
+ this.countdownInterval = setInterval(updateLabel, 1000);
830
+ }
831
+
832
+ private stopCountdown() {
833
+ if (this.countdownInterval) {
834
+ clearInterval(this.countdownInterval);
835
+ this.countdownInterval = null;
836
+ }
694
837
  }
695
838
 
696
839
  public addIndicator(
697
840
  id: string,
698
841
  plots: { [name: string]: IndicatorPlot },
699
842
  options: {
843
+ overlay?: boolean;
844
+ /** @deprecated Use overlay instead */
700
845
  isOverlay?: boolean;
701
846
  height?: number;
702
847
  titleColor?: string;
703
848
  controls?: { collapse?: boolean; maximize?: boolean };
704
- } = { isOverlay: false }
849
+ } = {}
705
850
  ): Indicator {
706
- const isOverlay = options.isOverlay ?? false;
851
+ // Handle backward compatibility: prefer 'overlay' over 'isOverlay'
852
+ const isOverlay = options.overlay !== undefined ? options.overlay : (options.isOverlay ?? false);
707
853
  let paneIndex = 0;
708
854
  if (!isOverlay) {
709
855
  // Find the next available pane index
@@ -730,10 +876,10 @@ export class QFChart implements ChartContext {
730
876
  return indicator;
731
877
  }
732
878
 
733
- // Deprecated: keeping for compatibility if needed, but redirects to addIndicator logic
879
+ /** @deprecated Use addIndicator instead */
734
880
  public setIndicator(id: string, plot: IndicatorPlot, isOverlay: boolean = false): void {
735
- // Wrap single plot into the new structure
736
- this.addIndicator(id, { [id]: plot }, { isOverlay });
881
+ // Wrap single plot into the new structure (backward compatibility)
882
+ this.addIndicator(id, { [id]: plot }, { overlay: isOverlay });
737
883
  }
738
884
 
739
885
  public removeIndicator(id: string): void {
@@ -780,6 +926,7 @@ export class QFChart implements ChartContext {
780
926
  }
781
927
 
782
928
  public destroy(): void {
929
+ this.stopCountdown();
783
930
  window.removeEventListener('resize', this.resize.bind(this));
784
931
  document.removeEventListener('fullscreenchange', this.onFullscreenChange);
785
932
  document.removeEventListener('keydown', this.onKeyDown);
@@ -851,9 +998,42 @@ export class QFChart implements ChartContext {
851
998
  ];
852
999
 
853
1000
  // 1. Calculate Layout
854
- const layout = LayoutManager.calculate(this.chart.getHeight(), this.indicators, this.options, this.isMainCollapsed, this.maximizedPaneId);
1001
+ const layout = LayoutManager.calculate(
1002
+ this.chart.getHeight(),
1003
+ this.indicators,
1004
+ this.options,
1005
+ this.isMainCollapsed,
1006
+ this.maximizedPaneId,
1007
+ this.marketData
1008
+ );
1009
+
1010
+ // Convert user-provided dataZoom start/end to account for padding
1011
+ // User's start/end refer to real data (0% = start of real data, 100% = end of real data)
1012
+ // We need to convert to padded data coordinates
1013
+ if (!currentZoomState && layout.dataZoom && this.marketData.length > 0) {
1014
+ const realDataLength = this.marketData.length;
1015
+ const totalLength = categoryData.length; // includes padding on both sides
1016
+ const paddingRatio = paddingPoints / totalLength;
1017
+ const dataRatio = realDataLength / totalLength;
1018
+
1019
+ layout.dataZoom.forEach((dz) => {
1020
+ // Convert user's start/end (0-100 referring to real data) to actual start/end (0-100 referring to padded data)
1021
+ if (dz.start !== undefined) {
1022
+ // User's start% of real data -> actual position in padded data
1023
+ const userStartFraction = dz.start / 100;
1024
+ const actualStartFraction = paddingRatio + userStartFraction * dataRatio;
1025
+ dz.start = actualStartFraction * 100;
1026
+ }
1027
+ if (dz.end !== undefined) {
1028
+ // User's end% of real data -> actual position in padded data
1029
+ const userEndFraction = dz.end / 100;
1030
+ const actualEndFraction = paddingRatio + userEndFraction * dataRatio;
1031
+ dz.end = actualEndFraction * 100;
1032
+ }
1033
+ });
1034
+ }
855
1035
 
856
- // Apply preserved zoom state if available
1036
+ // Apply preserved zoom state if available (this overrides the conversion above)
857
1037
  if (currentZoomState && layout.dataZoom) {
858
1038
  layout.dataZoom.forEach((dz) => {
859
1039
  dz.start = currentZoomState!.start;
@@ -882,7 +1062,9 @@ export class QFChart implements ChartContext {
882
1062
  layout.paneLayout,
883
1063
  categoryData.length,
884
1064
  paddingPoints,
885
- paddedOHLCVForShapes // Pass padded OHLCV
1065
+ paddedOHLCVForShapes, // Pass padded OHLCV
1066
+ layout.overlayYAxisMap, // Pass overlay Y-axis mapping
1067
+ layout.separatePaneYAxisOffset // Pass Y-axis offset for separate panes
886
1068
  );
887
1069
 
888
1070
  // Apply barColors to candlestick data
@@ -1210,6 +1392,7 @@ export class QFChart implements ChartContext {
1210
1392
  show: true,
1211
1393
  showContent: !!this.options.databox, // Show content only if databox is present
1212
1394
  trigger: 'axis',
1395
+ triggerOn: this.options.databox?.triggerOn ?? 'mousemove', // Control when to show tooltip/crosshair
1213
1396
  axisPointer: { type: 'cross', label: { backgroundColor: '#475569' } },
1214
1397
  backgroundColor: 'rgba(30, 41, 59, 0.9)',
1215
1398
  borderWidth: 1,
@@ -30,14 +30,35 @@ export class LayoutManager {
30
30
  indicators: Map<string, IndicatorType>,
31
31
  options: QFChartOptions,
32
32
  isMainCollapsed: boolean = false,
33
- maximizedPaneId: string | null = null
34
- ): LayoutResult {
33
+ maximizedPaneId: string | null = null,
34
+ marketData?: import('../types').OHLCV[]
35
+ ): LayoutResult & { overlayYAxisMap: Map<string, number>; separatePaneYAxisOffset: number } {
35
36
  // Calculate pixelToPercent early for maximized logic
36
37
  let pixelToPercent = 0;
37
38
  if (containerHeight > 0) {
38
39
  pixelToPercent = (1 / containerHeight) * 100;
39
40
  }
40
41
 
42
+ // Get Y-axis padding percentage (default 5%)
43
+ const yAxisPaddingPercent = options.yAxisPadding !== undefined ? options.yAxisPadding : 5;
44
+
45
+ // Create min/max functions that apply padding
46
+ const createMinFunction = (paddingPercent: number) => {
47
+ return (value: any) => {
48
+ const range = value.max - value.min;
49
+ const padding = range * (paddingPercent / 100);
50
+ return value.min - padding;
51
+ };
52
+ };
53
+
54
+ const createMaxFunction = (paddingPercent: number) => {
55
+ return (value: any) => {
56
+ const range = value.max - value.min;
57
+ const padding = range * (paddingPercent / 100);
58
+ return value.max + padding;
59
+ };
60
+ };
61
+
41
62
  // Identify unique separate panes (indices > 0) and sort them
42
63
  const separatePaneIndices = Array.from(indicators.values())
43
64
  .map((ind) => ind.paneIndex)
@@ -92,7 +113,11 @@ export class LayoutManager {
92
113
  const dzStart = options.dataZoom?.start ?? 50;
93
114
  const dzEnd = options.dataZoom?.end ?? 100;
94
115
 
95
- dataZoom.push({ type: 'inside', xAxisIndex: 'all', start: dzStart, end: dzEnd });
116
+ // Add 'inside' zoom only if zoomOnTouch is enabled (default true)
117
+ const zoomOnTouch = options.dataZoom?.zoomOnTouch ?? true;
118
+ if (zoomOnTouch) {
119
+ dataZoom.push({ type: 'inside', xAxisIndex: 'all', start: dzStart, end: dzEnd });
120
+ }
96
121
 
97
122
  // Need to know total panes to iterate
98
123
  const maxPaneIndex = hasSeparatePane ? Math.max(...separatePaneIndices) : 0;
@@ -132,15 +157,41 @@ export class LayoutManager {
132
157
  });
133
158
 
134
159
  // Y-Axis
160
+ // For maximized pane 0 (main), respect custom min/max if provided
161
+ let yMin: any;
162
+ let yMax: any;
163
+
164
+ if (i === 0 && maximizeTargetIndex === 0) {
165
+ // Main pane is maximized, use custom values if provided
166
+ yMin = options.yAxisMin !== undefined && options.yAxisMin !== 'auto' ? options.yAxisMin : createMinFunction(yAxisPaddingPercent);
167
+ yMax = options.yAxisMax !== undefined && options.yAxisMax !== 'auto' ? options.yAxisMax : createMaxFunction(yAxisPaddingPercent);
168
+ } else {
169
+ // Separate panes always use dynamic scaling
170
+ yMin = createMinFunction(yAxisPaddingPercent);
171
+ yMax = createMaxFunction(yAxisPaddingPercent);
172
+ }
173
+
135
174
  yAxis.push({
136
175
  position: 'right',
137
176
  gridIndex: i,
138
177
  show: isTarget,
139
178
  scale: true,
179
+ min: yMin,
180
+ max: yMax,
140
181
  axisLabel: {
141
182
  show: isTarget,
142
183
  color: '#94a3b8',
143
184
  fontFamily: options.fontFamily,
185
+ formatter: (value: number) => {
186
+ if (options.yAxisLabelFormatter) {
187
+ return options.yAxisLabelFormatter(value);
188
+ }
189
+ const decimals = options.yAxisDecimalPlaces !== undefined ? options.yAxisDecimalPlaces : 2;
190
+ if (typeof value === 'number') {
191
+ return value.toFixed(decimals);
192
+ }
193
+ return String(value);
194
+ },
144
195
  },
145
196
  splitLine: {
146
197
  show: isTarget,
@@ -176,6 +227,8 @@ export class LayoutManager {
176
227
  mainPaneHeight: maximizeTargetIndex === 0 ? 90 : 0,
177
228
  mainPaneTop: maximizeTargetIndex === 0 ? 5 : 0,
178
229
  pixelToPercent,
230
+ overlayYAxisMap: new Map(), // No overlays in maximized view
231
+ separatePaneYAxisOffset: 1, // In maximized view, no overlays, so separate panes start at 1
179
232
  };
180
233
  }
181
234
 
@@ -320,6 +373,16 @@ export class LayoutManager {
320
373
  show: !isMainCollapsed,
321
374
  color: '#94a3b8',
322
375
  fontFamily: options.fontFamily || 'sans-serif',
376
+ formatter: (value: number) => {
377
+ if (options.yAxisLabelFormatter) {
378
+ return options.yAxisLabelFormatter(value);
379
+ }
380
+ const decimals = options.yAxisDecimalPlaces !== undefined ? options.yAxisDecimalPlaces : 2;
381
+ if (typeof value === 'number') {
382
+ return value.toFixed(decimals);
383
+ }
384
+ return String(value);
385
+ },
323
386
  },
324
387
  axisTick: { show: !isMainCollapsed },
325
388
  axisPointer: {
@@ -354,10 +417,29 @@ export class LayoutManager {
354
417
 
355
418
  // --- Generate Y-Axes ---
356
419
  const yAxis: any[] = [];
357
- // Main Y-Axis
420
+
421
+ // Determine min/max for main Y-axis (respect custom values if provided)
422
+ let mainYAxisMin: any;
423
+ let mainYAxisMax: any;
424
+
425
+ if (options.yAxisMin !== undefined && options.yAxisMin !== 'auto') {
426
+ mainYAxisMin = options.yAxisMin;
427
+ } else {
428
+ mainYAxisMin = createMinFunction(yAxisPaddingPercent);
429
+ }
430
+
431
+ if (options.yAxisMax !== undefined && options.yAxisMax !== 'auto') {
432
+ mainYAxisMax = options.yAxisMax;
433
+ } else {
434
+ mainYAxisMax = createMaxFunction(yAxisPaddingPercent);
435
+ }
436
+
437
+ // Main Y-Axis (for candlesticks)
358
438
  yAxis.push({
359
439
  position: 'right',
360
440
  scale: true,
441
+ min: mainYAxisMin,
442
+ max: mainYAxisMax,
361
443
  gridIndex: 0,
362
444
  splitLine: {
363
445
  show: !isMainCollapsed,
@@ -368,14 +450,131 @@ export class LayoutManager {
368
450
  show: !isMainCollapsed,
369
451
  color: '#94a3b8',
370
452
  fontFamily: options.fontFamily || 'sans-serif',
453
+ formatter: (value: number) => {
454
+ if (options.yAxisLabelFormatter) {
455
+ return options.yAxisLabelFormatter(value);
456
+ }
457
+ const decimals = options.yAxisDecimalPlaces !== undefined ? options.yAxisDecimalPlaces : 2;
458
+ if (typeof value === 'number') {
459
+ return value.toFixed(decimals);
460
+ }
461
+ return String(value);
462
+ },
371
463
  },
372
464
  });
373
465
 
374
- // Separate Panes Y-Axes
466
+ // Create separate Y-axes for overlay plots that are incompatible with price range
467
+ // Analyze each PLOT separately, not entire indicators
468
+ let nextYAxisIndex = 1;
469
+
470
+ // Calculate price range if market data is available
471
+ let priceMin = -Infinity;
472
+ let priceMax = Infinity;
473
+ if (marketData && marketData.length > 0) {
474
+ priceMin = Math.min(...marketData.map((d) => d.low));
475
+ priceMax = Math.max(...marketData.map((d) => d.high));
476
+ }
477
+
478
+ // Map to store plot-specific Y-axis assignments (key: "indicatorId::plotName")
479
+ const overlayYAxisMap: Map<string, number> = new Map();
480
+
481
+ indicators.forEach((indicator, id) => {
482
+ if (indicator.paneIndex === 0 && !indicator.collapsed) {
483
+ // This is an overlay on the main pane
484
+ // Analyze EACH PLOT separately
485
+
486
+ if (marketData && marketData.length > 0) {
487
+ Object.entries(indicator.plots).forEach(([plotName, plot]) => {
488
+ const plotKey = `${id}::${plotName}`;
489
+
490
+ // Skip visual-only plot types that should never affect Y-axis scaling
491
+ // EXCEPTION: shapes with abovebar/belowbar must stay on main Y-axis
492
+ const visualOnlyStyles = ['background', 'barcolor', 'char'];
493
+
494
+ // Check if this is a shape with price-relative positioning
495
+ const isShapeWithPriceLocation =
496
+ plot.options.style === 'shape' &&
497
+ (plot.options.location === 'abovebar' || plot.options.location === 'belowbar');
498
+
499
+ if (visualOnlyStyles.includes(plot.options.style)) {
500
+ // Assign these to a separate Y-axis so they don't affect price scale
501
+ if (!overlayYAxisMap.has(plotKey)) {
502
+ overlayYAxisMap.set(plotKey, nextYAxisIndex);
503
+ nextYAxisIndex++;
504
+ }
505
+ return; // Skip further processing for this plot
506
+ }
507
+
508
+ // If it's a shape but NOT with price-relative positioning, treat as visual-only
509
+ if (plot.options.style === 'shape' && !isShapeWithPriceLocation) {
510
+ if (!overlayYAxisMap.has(plotKey)) {
511
+ overlayYAxisMap.set(plotKey, nextYAxisIndex);
512
+ nextYAxisIndex++;
513
+ }
514
+ return;
515
+ }
516
+
517
+ const values: number[] = [];
518
+
519
+ // Extract values for this specific plot
520
+ Object.values(plot.data).forEach((value) => {
521
+ if (typeof value === 'number' && !isNaN(value) && isFinite(value)) {
522
+ values.push(value);
523
+ }
524
+ });
525
+
526
+ if (values.length > 0) {
527
+ const plotMin = Math.min(...values);
528
+ const plotMax = Math.max(...values);
529
+ const plotRange = plotMax - plotMin;
530
+ const priceRange = priceMax - priceMin;
531
+
532
+ // Check if this plot's range is compatible with price range
533
+ // Compatible = within price bounds with similar magnitude
534
+ const isWithinBounds = plotMin >= priceMin * 0.5 && plotMax <= priceMax * 1.5;
535
+ const hasSimilarMagnitude = plotRange > priceRange * 0.01; // At least 1% of price range
536
+
537
+ const isCompatible = isWithinBounds && hasSimilarMagnitude;
538
+
539
+ if (!isCompatible) {
540
+ // This plot needs its own Y-axis - check if we already assigned one
541
+ if (!overlayYAxisMap.has(plotKey)) {
542
+ overlayYAxisMap.set(plotKey, nextYAxisIndex);
543
+ nextYAxisIndex++;
544
+ }
545
+ }
546
+ // Compatible plots stay on yAxisIndex: 0 (not added to map)
547
+ }
548
+ });
549
+ }
550
+ }
551
+ });
552
+
553
+ // Create Y-axes for incompatible plots
554
+ // nextYAxisIndex already incremented in the loop above, so we know how many axes we need
555
+ const numOverlayAxes = overlayYAxisMap.size > 0 ? nextYAxisIndex - 1 : 0;
556
+ for (let i = 0; i < numOverlayAxes; i++) {
557
+ yAxis.push({
558
+ position: 'left',
559
+ scale: true,
560
+ min: createMinFunction(yAxisPaddingPercent),
561
+ max: createMaxFunction(yAxisPaddingPercent),
562
+ gridIndex: 0,
563
+ show: false, // Hide the axis visual elements
564
+ splitLine: { show: false },
565
+ axisLine: { show: false },
566
+ axisLabel: { show: false },
567
+ });
568
+ }
569
+
570
+ // Separate Panes Y-Axes (start after overlay axes)
571
+ const separatePaneYAxisOffset = nextYAxisIndex;
375
572
  paneConfigs.forEach((pane, i) => {
376
573
  yAxis.push({
377
574
  position: 'right',
378
575
  scale: true,
576
+ min: createMinFunction(yAxisPaddingPercent),
577
+ max: createMaxFunction(yAxisPaddingPercent),
379
578
  gridIndex: i + 1,
380
579
  splitLine: {
381
580
  show: !pane.isCollapsed,
@@ -386,6 +585,16 @@ export class LayoutManager {
386
585
  color: '#94a3b8',
387
586
  fontFamily: options.fontFamily || 'sans-serif',
388
587
  fontSize: 10,
588
+ formatter: (value: number) => {
589
+ if (options.yAxisLabelFormatter) {
590
+ return options.yAxisLabelFormatter(value);
591
+ }
592
+ const decimals = options.yAxisDecimalPlaces !== undefined ? options.yAxisDecimalPlaces : 2;
593
+ if (typeof value === 'number') {
594
+ return value.toFixed(decimals);
595
+ }
596
+ return String(value);
597
+ },
389
598
  },
390
599
  axisLine: { show: !pane.isCollapsed, lineStyle: { color: '#334155' } },
391
600
  });
@@ -394,12 +603,16 @@ export class LayoutManager {
394
603
  // --- Generate DataZoom ---
395
604
  const dataZoom: any[] = [];
396
605
  if (dzVisible) {
397
- dataZoom.push({
398
- type: 'inside',
399
- xAxisIndex: allXAxisIndices,
400
- start: dzStart,
401
- end: dzEnd,
402
- });
606
+ // Add 'inside' zoom (pan/drag) only if zoomOnTouch is enabled (default true)
607
+ const zoomOnTouch = options.dataZoom?.zoomOnTouch ?? true;
608
+ if (zoomOnTouch) {
609
+ dataZoom.push({
610
+ type: 'inside',
611
+ xAxisIndex: allXAxisIndices,
612
+ start: dzStart,
613
+ end: dzEnd,
614
+ });
615
+ }
403
616
 
404
617
  if (dzPosition === 'top') {
405
618
  dataZoom.push({
@@ -437,6 +650,8 @@ export class LayoutManager {
437
650
  mainPaneHeight: mainHeightVal,
438
651
  mainPaneTop,
439
652
  pixelToPercent,
653
+ overlayYAxisMap,
654
+ separatePaneYAxisOffset,
440
655
  };
441
656
  }
442
657