@qfo/qfchart 0.5.7 → 0.6.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.
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]
@@ -649,15 +673,33 @@ export class QFChart implements ChartContext {
649
673
 
650
674
  const paddedOHLCVForShapes = [...Array(paddingPoints).fill(null), ...this.marketData, ...Array(paddingPoints).fill(null)];
651
675
 
652
- const indicatorSeries = SeriesBuilder.buildIndicatorSeries(
676
+ const { series: indicatorSeries, barColors } = SeriesBuilder.buildIndicatorSeries(
653
677
  this.indicators,
654
678
  this.timeToIndex,
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
 
687
+ // Apply barColors to candlestick data
688
+ const coloredCandlestickData = paddedCandlestickData.map((candle: any, i: number) => {
689
+ if (barColors[i]) {
690
+ return {
691
+ value: candle.value || candle,
692
+ itemStyle: {
693
+ color: barColors[i],
694
+ color0: barColors[i],
695
+ borderColor: barColors[i],
696
+ borderColor0: barColors[i],
697
+ },
698
+ };
699
+ }
700
+ return candle;
701
+ });
702
+
661
703
  // Update only the data arrays in the option, not the full config
662
704
  const updateOption: any = {
663
705
  xAxis: currentOption.xAxis.map((axis: any, index: number) => ({
@@ -665,16 +707,133 @@ export class QFChart implements ChartContext {
665
707
  })),
666
708
  series: [
667
709
  {
668
- data: paddedCandlestickData,
710
+ data: coloredCandlestickData,
711
+ markLine: candlestickSeries.markLine, // Ensure markLine is updated
669
712
  },
670
- ...indicatorSeries.map((s) => ({
671
- data: s.data,
672
- })),
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
+ }),
673
723
  ],
674
724
  };
675
725
 
676
726
  // Merge the update (don't replace entire config)
677
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
+ }
678
837
  }
679
838
 
680
839
  public addIndicator(
@@ -764,6 +923,7 @@ export class QFChart implements ChartContext {
764
923
  }
765
924
 
766
925
  public destroy(): void {
926
+ this.stopCountdown();
767
927
  window.removeEventListener('resize', this.resize.bind(this));
768
928
  document.removeEventListener('fullscreenchange', this.onFullscreenChange);
769
929
  document.removeEventListener('keydown', this.onKeyDown);
@@ -835,9 +995,42 @@ export class QFChart implements ChartContext {
835
995
  ];
836
996
 
837
997
  // 1. Calculate Layout
838
- const layout = LayoutManager.calculate(this.chart.getHeight(), this.indicators, this.options, this.isMainCollapsed, this.maximizedPaneId);
998
+ const layout = LayoutManager.calculate(
999
+ this.chart.getHeight(),
1000
+ this.indicators,
1001
+ this.options,
1002
+ this.isMainCollapsed,
1003
+ this.maximizedPaneId,
1004
+ this.marketData
1005
+ );
1006
+
1007
+ // Convert user-provided dataZoom start/end to account for padding
1008
+ // User's start/end refer to real data (0% = start of real data, 100% = end of real data)
1009
+ // We need to convert to padded data coordinates
1010
+ if (!currentZoomState && layout.dataZoom && this.marketData.length > 0) {
1011
+ const realDataLength = this.marketData.length;
1012
+ const totalLength = categoryData.length; // includes padding on both sides
1013
+ const paddingRatio = paddingPoints / totalLength;
1014
+ const dataRatio = realDataLength / totalLength;
1015
+
1016
+ layout.dataZoom.forEach((dz) => {
1017
+ // Convert user's start/end (0-100 referring to real data) to actual start/end (0-100 referring to padded data)
1018
+ if (dz.start !== undefined) {
1019
+ // User's start% of real data -> actual position in padded data
1020
+ const userStartFraction = dz.start / 100;
1021
+ const actualStartFraction = paddingRatio + userStartFraction * dataRatio;
1022
+ dz.start = actualStartFraction * 100;
1023
+ }
1024
+ if (dz.end !== undefined) {
1025
+ // User's end% of real data -> actual position in padded data
1026
+ const userEndFraction = dz.end / 100;
1027
+ const actualEndFraction = paddingRatio + userEndFraction * dataRatio;
1028
+ dz.end = actualEndFraction * 100;
1029
+ }
1030
+ });
1031
+ }
839
1032
 
840
- // Apply preserved zoom state if available
1033
+ // Apply preserved zoom state if available (this overrides the conversion above)
841
1034
  if (currentZoomState && layout.dataZoom) {
842
1035
  layout.dataZoom.forEach((dz) => {
843
1036
  dz.start = currentZoomState!.start;
@@ -860,15 +1053,33 @@ export class QFChart implements ChartContext {
860
1053
  // Build array of OHLCV aligned with indices for shape positioning
861
1054
  const paddedOHLCVForShapes = [...Array(paddingPoints).fill(null), ...this.marketData, ...Array(paddingPoints).fill(null)];
862
1055
 
863
- const indicatorSeries = SeriesBuilder.buildIndicatorSeries(
1056
+ const { series: indicatorSeries, barColors } = SeriesBuilder.buildIndicatorSeries(
864
1057
  this.indicators,
865
1058
  this.timeToIndex,
866
1059
  layout.paneLayout,
867
1060
  categoryData.length,
868
1061
  paddingPoints,
869
- paddedOHLCVForShapes // Pass padded OHLCV
1062
+ paddedOHLCVForShapes, // Pass padded OHLCV
1063
+ layout.overlayYAxisMap, // Pass overlay Y-axis mapping
1064
+ layout.separatePaneYAxisOffset // Pass Y-axis offset for separate panes
870
1065
  );
871
1066
 
1067
+ // Apply barColors to candlestick data
1068
+ candlestickSeries.data = candlestickSeries.data.map((candle: any, i: number) => {
1069
+ if (barColors[i]) {
1070
+ return {
1071
+ value: candle.value || candle,
1072
+ itemStyle: {
1073
+ color: barColors[i],
1074
+ color0: barColors[i],
1075
+ borderColor: barColors[i],
1076
+ borderColor0: barColors[i],
1077
+ },
1078
+ };
1079
+ }
1080
+ return candle;
1081
+ });
1082
+
872
1083
  // 3. Build Graphics
873
1084
  const graphic = GraphicBuilder.build(layout, this.options, this.toggleIndicator.bind(this), this.isMainCollapsed, this.maximizedPaneId);
874
1085
 
@@ -1178,6 +1389,7 @@ export class QFChart implements ChartContext {
1178
1389
  show: true,
1179
1390
  showContent: !!this.options.databox, // Show content only if databox is present
1180
1391
  trigger: 'axis',
1392
+ triggerOn: this.options.databox?.triggerOn ?? 'mousemove', // Control when to show tooltip/crosshair
1181
1393
  axisPointer: { type: 'cross', label: { backgroundColor: '#475569' } },
1182
1394
  backgroundColor: 'rgba(30, 41, 59, 0.9)',
1183
1395
  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,115 @@ 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
+ const visualOnlyStyles = ['background', 'barcolor', 'shape', 'char'];
492
+ if (visualOnlyStyles.includes(plot.options.style)) {
493
+ // Assign these to a separate Y-axis so they don't affect price scale
494
+ if (!overlayYAxisMap.has(plotKey)) {
495
+ overlayYAxisMap.set(plotKey, nextYAxisIndex);
496
+ nextYAxisIndex++;
497
+ }
498
+ return; // Skip further processing for this plot
499
+ }
500
+
501
+ const values: number[] = [];
502
+
503
+ // Extract values for this specific plot
504
+ Object.values(plot.data).forEach((value) => {
505
+ if (typeof value === 'number' && !isNaN(value) && isFinite(value)) {
506
+ values.push(value);
507
+ }
508
+ });
509
+
510
+ if (values.length > 0) {
511
+ const plotMin = Math.min(...values);
512
+ const plotMax = Math.max(...values);
513
+ const plotRange = plotMax - plotMin;
514
+ const priceRange = priceMax - priceMin;
515
+
516
+ // Check if this plot's range is compatible with price range
517
+ // Compatible = within price bounds with similar magnitude
518
+ const isWithinBounds = plotMin >= priceMin * 0.5 && plotMax <= priceMax * 1.5;
519
+ const hasSimilarMagnitude = plotRange > priceRange * 0.01; // At least 1% of price range
520
+
521
+ const isCompatible = isWithinBounds && hasSimilarMagnitude;
522
+
523
+ if (!isCompatible) {
524
+ // This plot needs its own Y-axis - check if we already assigned one
525
+ if (!overlayYAxisMap.has(plotKey)) {
526
+ overlayYAxisMap.set(plotKey, nextYAxisIndex);
527
+ nextYAxisIndex++;
528
+ }
529
+ }
530
+ // Compatible plots stay on yAxisIndex: 0 (not added to map)
531
+ }
532
+ });
533
+ }
534
+ }
535
+ });
536
+
537
+ // Create Y-axes for incompatible plots
538
+ // nextYAxisIndex already incremented in the loop above, so we know how many axes we need
539
+ const numOverlayAxes = overlayYAxisMap.size > 0 ? nextYAxisIndex - 1 : 0;
540
+ for (let i = 0; i < numOverlayAxes; i++) {
541
+ yAxis.push({
542
+ position: 'left',
543
+ scale: true,
544
+ min: createMinFunction(yAxisPaddingPercent),
545
+ max: createMaxFunction(yAxisPaddingPercent),
546
+ gridIndex: 0,
547
+ show: false, // Hide the axis visual elements
548
+ splitLine: { show: false },
549
+ axisLine: { show: false },
550
+ axisLabel: { show: false },
551
+ });
552
+ }
553
+
554
+ // Separate Panes Y-Axes (start after overlay axes)
555
+ const separatePaneYAxisOffset = nextYAxisIndex;
375
556
  paneConfigs.forEach((pane, i) => {
376
557
  yAxis.push({
377
558
  position: 'right',
378
559
  scale: true,
560
+ min: createMinFunction(yAxisPaddingPercent),
561
+ max: createMaxFunction(yAxisPaddingPercent),
379
562
  gridIndex: i + 1,
380
563
  splitLine: {
381
564
  show: !pane.isCollapsed,
@@ -386,6 +569,16 @@ export class LayoutManager {
386
569
  color: '#94a3b8',
387
570
  fontFamily: options.fontFamily || 'sans-serif',
388
571
  fontSize: 10,
572
+ formatter: (value: number) => {
573
+ if (options.yAxisLabelFormatter) {
574
+ return options.yAxisLabelFormatter(value);
575
+ }
576
+ const decimals = options.yAxisDecimalPlaces !== undefined ? options.yAxisDecimalPlaces : 2;
577
+ if (typeof value === 'number') {
578
+ return value.toFixed(decimals);
579
+ }
580
+ return String(value);
581
+ },
389
582
  },
390
583
  axisLine: { show: !pane.isCollapsed, lineStyle: { color: '#334155' } },
391
584
  });
@@ -394,12 +587,16 @@ export class LayoutManager {
394
587
  // --- Generate DataZoom ---
395
588
  const dataZoom: any[] = [];
396
589
  if (dzVisible) {
397
- dataZoom.push({
398
- type: 'inside',
399
- xAxisIndex: allXAxisIndices,
400
- start: dzStart,
401
- end: dzEnd,
402
- });
590
+ // Add 'inside' zoom (pan/drag) only if zoomOnTouch is enabled (default true)
591
+ const zoomOnTouch = options.dataZoom?.zoomOnTouch ?? true;
592
+ if (zoomOnTouch) {
593
+ dataZoom.push({
594
+ type: 'inside',
595
+ xAxisIndex: allXAxisIndices,
596
+ start: dzStart,
597
+ end: dzEnd,
598
+ });
599
+ }
403
600
 
404
601
  if (dzPosition === 'top') {
405
602
  dataZoom.push({
@@ -437,6 +634,8 @@ export class LayoutManager {
437
634
  mainPaneHeight: mainHeightVal,
438
635
  mainPaneTop,
439
636
  pixelToPercent,
637
+ overlayYAxisMap,
638
+ separatePaneYAxisOffset,
440
639
  };
441
640
  }
442
641