@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/dist/index.d.ts +23 -2
- package/dist/qfchart.min.browser.js +14 -13
- package/dist/qfchart.min.es.js +15 -14
- package/package.json +1 -1
- package/src/QFChart.ts +226 -14
- package/src/components/LayoutManager.ts +210 -11
- package/src/components/PluginManager.ts +205 -210
- package/src/components/SeriesBuilder.ts +271 -18
- package/src/types.ts +34 -3
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]
|
|
@@ -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:
|
|
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(
|
|
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
|
-
|
|
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,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
|
-
//
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
|