@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/dist/index.d.ts +21 -0
- package/dist/qfchart.min.browser.js +15 -14
- package/dist/qfchart.min.es.js +12 -11
- package/package.json +1 -1
- package/src/QFChart.ts +199 -16
- package/src/components/LayoutManager.ts +226 -11
- package/src/components/PluginManager.ts +205 -210
- package/src/components/SeriesBuilder.ts +80 -18
- package/src/types.ts +17 -1
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) =>
|
|
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
|
|
638
|
+
const candlestickSeries = SeriesBuilder.buildCandlestickSeries(this.marketData, this.options);
|
|
626
639
|
const emptyCandle = { value: [NaN, NaN, NaN, NaN], itemStyle: { opacity: 0 } };
|
|
627
|
-
const paddedCandlestickData = [
|
|
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(
|
|
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
|
-
} = {
|
|
849
|
+
} = {}
|
|
705
850
|
): Indicator {
|
|
706
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
|