@qfo/qfchart 0.6.8 → 0.7.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.
@@ -1,679 +1,682 @@
1
- import { QFChartOptions, Indicator as IndicatorType, OHLCV } from '../types';
2
- import { AxisUtils } from '../utils/AxisUtils';
3
-
4
- export interface PaneConfiguration {
5
- index: number;
6
- height: number;
7
- top: number;
8
- isCollapsed: boolean;
9
- indicatorId?: string;
10
- titleColor?: string;
11
- controls?: {
12
- collapse?: boolean;
13
- maximize?: boolean;
14
- };
15
- }
16
-
17
- export interface LayoutResult {
18
- grid: any[];
19
- xAxis: any[];
20
- yAxis: any[];
21
- dataZoom: any[];
22
- paneLayout: PaneConfiguration[];
23
- mainPaneHeight: number;
24
- mainPaneTop: number;
25
- pixelToPercent: number;
26
- }
27
-
28
- export class LayoutManager {
29
- public static calculate(
30
- containerHeight: number,
31
- indicators: Map<string, IndicatorType>,
32
- options: QFChartOptions,
33
- isMainCollapsed: boolean = false,
34
- maximizedPaneId: string | null = null,
35
- marketData?: import('../types').OHLCV[]
36
- ): LayoutResult & { overlayYAxisMap: Map<string, number>; separatePaneYAxisOffset: number } {
37
- // Calculate pixelToPercent early for maximized logic
38
- let pixelToPercent = 0;
39
- if (containerHeight > 0) {
40
- pixelToPercent = (1 / containerHeight) * 100;
41
- }
42
-
43
- // Get Y-axis padding percentage (default 5%)
44
- const yAxisPaddingPercent = options.yAxisPadding !== undefined ? options.yAxisPadding : 5;
45
-
46
- // Identify unique separate panes (indices > 0) and sort them
47
- const separatePaneIndices = Array.from(indicators.values())
48
- .map((ind) => ind.paneIndex)
49
- .filter((idx) => idx > 0)
50
- .sort((a, b) => a - b)
51
- .filter((value, index, self) => self.indexOf(value) === index); // Unique
52
-
53
- const hasSeparatePane = separatePaneIndices.length > 0;
54
-
55
- // DataZoom Configuration
56
- const dzVisible = options.dataZoom?.visible ?? true;
57
- const dzPosition = options.dataZoom?.position ?? 'top';
58
- const dzHeight = options.dataZoom?.height ?? 6;
59
- const dzStart = options.dataZoom?.start ?? 0;
60
- const dzEnd = options.dataZoom?.end ?? 100;
61
-
62
- // Layout Calculation
63
- let mainPaneTop = 8;
64
- let chartAreaBottom = 92; // Default if no dataZoom at bottom
65
-
66
- // Maximized State Logic
67
- let maximizeTargetIndex = -1; // -1 = none
68
-
69
- if (maximizedPaneId) {
70
- if (maximizedPaneId === 'main') {
71
- maximizeTargetIndex = 0;
72
- } else {
73
- const ind = indicators.get(maximizedPaneId);
74
- if (ind) {
75
- maximizeTargetIndex = ind.paneIndex;
76
- }
77
- }
78
- }
79
-
80
- if (maximizeTargetIndex !== -1) {
81
- // Special Layout for Maximize
82
- // We must generate grid/axis definitions for ALL indices to maintain series mapping,
83
- // but hide the non-maximized ones.
84
-
85
- const grid: any[] = [];
86
- const xAxis: any[] = [];
87
- const yAxis: any[] = [];
88
- const dataZoom: any[] = []; // Hide slider, keep inside?
89
-
90
- // DataZoom: keep inside, maybe slider if main?
91
- // Let's keep strict maximize: Full container.
92
- // Use defaults for maximize if not available, or preserve logic?
93
- // The calculateMaximized doesn't use LayoutManager.calculate directly but inline logic.
94
- // It should probably respect the same zoom?
95
- // But here we are inside LayoutManager.calculate.
96
-
97
- const dzStart = options.dataZoom?.start ?? 50;
98
- const dzEnd = options.dataZoom?.end ?? 100;
99
-
100
- // Add 'inside' zoom only if zoomOnTouch is enabled (default true)
101
- const zoomOnTouch = options.dataZoom?.zoomOnTouch ?? true;
102
- if (zoomOnTouch) {
103
- dataZoom.push({ type: 'inside', xAxisIndex: 'all', start: dzStart, end: dzEnd });
104
- }
105
-
106
- // Need to know total panes to iterate
107
- const maxPaneIndex = hasSeparatePane ? Math.max(...separatePaneIndices) : 0;
108
-
109
- const paneConfigs: PaneConfiguration[] = []; // For GraphicBuilder title placement
110
-
111
- // Iterate 0 to maxPaneIndex
112
- for (let i = 0; i <= maxPaneIndex; i++) {
113
- const isTarget = i === maximizeTargetIndex;
114
-
115
- // Grid
116
- grid.push({
117
- left: '10%',
118
- right: '10%',
119
- top: isTarget ? '5%' : '0%',
120
- height: isTarget ? '90%' : '0%',
121
- show: isTarget,
122
- containLabel: false,
123
- });
124
-
125
- // X-Axis
126
- xAxis.push({
127
- type: 'category',
128
- gridIndex: i,
129
- data: [],
130
- show: isTarget,
131
- axisLabel: {
132
- show: isTarget,
133
- color: '#94a3b8',
134
- fontFamily: options.fontFamily,
135
- },
136
- axisLine: { show: isTarget, lineStyle: { color: '#334155' } },
137
- splitLine: {
138
- show: isTarget,
139
- lineStyle: { color: '#334155', opacity: 0.5 },
140
- },
141
- });
142
-
143
- // Y-Axis
144
- // For maximized pane 0 (main), respect custom min/max if provided
145
- let yMin: any;
146
- let yMax: any;
147
-
148
- if (i === 0 && maximizeTargetIndex === 0) {
149
- // Main pane is maximized, use custom values if provided
150
- yMin =
151
- options.yAxisMin !== undefined && options.yAxisMin !== 'auto'
152
- ? options.yAxisMin
153
- : AxisUtils.createMinFunction(yAxisPaddingPercent);
154
- yMax =
155
- options.yAxisMax !== undefined && options.yAxisMax !== 'auto'
156
- ? options.yAxisMax
157
- : AxisUtils.createMaxFunction(yAxisPaddingPercent);
158
- } else {
159
- // Separate panes always use dynamic scaling
160
- yMin = AxisUtils.createMinFunction(yAxisPaddingPercent);
161
- yMax = AxisUtils.createMaxFunction(yAxisPaddingPercent);
162
- }
163
-
164
- yAxis.push({
165
- position: 'right',
166
- gridIndex: i,
167
- show: isTarget,
168
- scale: true,
169
- min: yMin,
170
- max: yMax,
171
- axisLabel: {
172
- show: isTarget,
173
- color: '#94a3b8',
174
- fontFamily: options.fontFamily,
175
- formatter: (value: number) => {
176
- if (options.yAxisLabelFormatter) {
177
- return options.yAxisLabelFormatter(value);
178
- }
179
- const decimals = options.yAxisDecimalPlaces !== undefined
180
- ? options.yAxisDecimalPlaces
181
- : AxisUtils.autoDetectDecimals(marketData as OHLCV[]);
182
- return AxisUtils.formatValue(value, decimals);
183
- },
184
- },
185
- splitLine: {
186
- show: isTarget,
187
- lineStyle: { color: '#334155', opacity: 0.5 },
188
- },
189
- });
190
-
191
- // Reconstruct Pane Config for GraphicBuilder
192
- // We need to return `paneLayout` so GraphicBuilder can draw the Restore button
193
- if (i > 0) {
194
- // Find indicator for this pane
195
- const ind = Array.from(indicators.values()).find((ind) => ind.paneIndex === i);
196
- if (ind) {
197
- paneConfigs.push({
198
- index: i,
199
- height: isTarget ? 90 : 0,
200
- top: isTarget ? 5 : 0,
201
- isCollapsed: false,
202
- indicatorId: ind.id,
203
- titleColor: ind.titleColor,
204
- controls: ind.controls,
205
- });
206
- }
207
- }
208
- }
209
-
210
- return {
211
- grid,
212
- xAxis,
213
- yAxis,
214
- dataZoom,
215
- paneLayout: paneConfigs,
216
- mainPaneHeight: maximizeTargetIndex === 0 ? 90 : 0,
217
- mainPaneTop: maximizeTargetIndex === 0 ? 5 : 0,
218
- pixelToPercent,
219
- overlayYAxisMap: new Map(), // No overlays in maximized view
220
- separatePaneYAxisOffset: 1, // In maximized view, no overlays, so separate panes start at 1
221
- };
222
- }
223
-
224
- if (dzVisible) {
225
- if (dzPosition === 'top') {
226
- // DataZoom takes top 0% to dzHeight%
227
- // Main chart starts below it with a small gap
228
- mainPaneTop = dzHeight + 4; // dzHeight + 4% gap
229
- chartAreaBottom = 95; // Use more space at bottom since slider is gone
230
- } else {
231
- // DataZoom takes bottom
232
- // Chart ends at 100 - dzHeight - margin
233
- chartAreaBottom = 100 - dzHeight - 2;
234
- mainPaneTop = 8;
235
- }
236
- } else {
237
- // No data zoom
238
- mainPaneTop = 5;
239
- chartAreaBottom = 95;
240
- }
241
-
242
- // We need to calculate height distribution dynamically to avoid overlap.
243
- // Calculate gap in percent
244
- let gapPercent = 5;
245
- if (containerHeight > 0) {
246
- gapPercent = (20 / containerHeight) * 100;
247
- }
248
-
249
- let mainHeightVal = 75; // Default if no separate pane
250
-
251
- // Prepare separate panes configuration
252
- let paneConfigs: PaneConfiguration[] = [];
253
-
254
- if (hasSeparatePane) {
255
- // Resolve heights for all separate panes
256
- // 1. Identify panes and their requested heights
257
- const panes = separatePaneIndices.map((idx) => {
258
- const ind = Array.from(indicators.values()).find((i) => i.paneIndex === idx);
259
- return {
260
- index: idx,
261
- requestedHeight: ind?.height,
262
- isCollapsed: ind?.collapsed ?? false,
263
- indicatorId: ind?.id,
264
- titleColor: ind?.titleColor,
265
- controls: ind?.controls,
266
- };
267
- });
268
-
269
- // 2. Assign actual heights
270
- // If collapsed, use small fixed height (e.g. 3%)
271
- const resolvedPanes = panes.map((p) => ({
272
- ...p,
273
- height: p.isCollapsed ? 3 : p.requestedHeight !== undefined ? p.requestedHeight : 15,
274
- }));
275
-
276
- // 3. Calculate total space needed for indicators
277
- const totalIndicatorHeight = resolvedPanes.reduce((sum, p) => sum + p.height, 0);
278
- const totalGaps = resolvedPanes.length * gapPercent;
279
- const totalBottomSpace = totalIndicatorHeight + totalGaps;
280
-
281
- // 4. Calculate Main Chart Height
282
- // Available space = chartAreaBottom - mainPaneTop;
283
- const totalAvailable = chartAreaBottom - mainPaneTop;
284
- mainHeightVal = totalAvailable - totalBottomSpace;
285
-
286
- if (isMainCollapsed) {
287
- mainHeightVal = 3;
288
- } else {
289
- // Safety check: ensure main chart has at least some space (e.g. 20%)
290
- if (mainHeightVal < 20) {
291
- mainHeightVal = Math.max(mainHeightVal, 10);
292
- }
293
- }
294
-
295
- // 5. Calculate positions
296
- let currentTop = mainPaneTop + mainHeightVal + gapPercent;
297
-
298
- paneConfigs = resolvedPanes.map((p) => {
299
- const config = {
300
- index: p.index,
301
- height: p.height,
302
- top: currentTop,
303
- isCollapsed: p.isCollapsed,
304
- indicatorId: p.indicatorId,
305
- titleColor: p.titleColor,
306
- controls: p.controls,
307
- };
308
- currentTop += p.height + gapPercent;
309
- return config;
310
- });
311
- } else {
312
- mainHeightVal = chartAreaBottom - mainPaneTop;
313
- if (isMainCollapsed) {
314
- mainHeightVal = 3;
315
- }
316
- }
317
-
318
- // --- Generate Grids ---
319
- const grid: any[] = [];
320
- // Main Grid (index 0)
321
- grid.push({
322
- left: '10%',
323
- right: '10%',
324
- top: mainPaneTop + '%',
325
- height: mainHeightVal + '%',
326
- containLabel: false, // We handle margins explicitly
327
- });
328
-
329
- // Separate Panes Grids
330
- paneConfigs.forEach((pane) => {
331
- grid.push({
332
- left: '10%',
333
- right: '10%',
334
- top: pane.top + '%',
335
- height: pane.height + '%',
336
- containLabel: false,
337
- });
338
- });
339
-
340
- // --- Generate X-Axes ---
341
- const allXAxisIndices = [0, ...paneConfigs.map((_, i) => i + 1)];
342
- const xAxis: any[] = [];
343
-
344
- // Main X-Axis
345
- const isMainBottom = paneConfigs.length === 0;
346
- xAxis.push({
347
- type: 'category',
348
- data: [], // Will be filled by SeriesBuilder or QFChart
349
- gridIndex: 0,
350
- scale: true,
351
- // boundaryGap will be set in QFChart.ts based on padding option
352
- axisLine: {
353
- onZero: false,
354
- show: !isMainCollapsed,
355
- lineStyle: { color: '#334155' },
356
- },
357
- splitLine: {
358
- show: !isMainCollapsed,
359
- lineStyle: { color: '#334155', opacity: 0.5 },
360
- },
361
- axisLabel: {
362
- show: !isMainCollapsed,
363
- color: '#94a3b8',
364
- fontFamily: options.fontFamily || 'sans-serif',
365
- formatter: (value: number) => {
366
- if (options.yAxisLabelFormatter) {
367
- return options.yAxisLabelFormatter(value);
368
- }
369
- const decimals = options.yAxisDecimalPlaces !== undefined
370
- ? options.yAxisDecimalPlaces
371
- : AxisUtils.autoDetectDecimals(marketData as OHLCV[]);
372
- return AxisUtils.formatValue(value, decimals);
373
- },
374
- },
375
- axisTick: { show: !isMainCollapsed },
376
- axisPointer: {
377
- label: {
378
- show: isMainBottom,
379
- fontSize: 11,
380
- backgroundColor: '#475569',
381
- },
382
- },
383
- });
384
-
385
- // Separate Panes X-Axes
386
- paneConfigs.forEach((pane, i) => {
387
- const isBottom = i === paneConfigs.length - 1;
388
- xAxis.push({
389
- type: 'category',
390
- gridIndex: i + 1, // 0 is main
391
- data: [], // Shared data
392
- axisLabel: { show: false }, // Hide labels on indicator panes
393
- axisLine: { show: !pane.isCollapsed, lineStyle: { color: '#334155' } },
394
- axisTick: { show: false },
395
- splitLine: { show: false },
396
- axisPointer: {
397
- label: {
398
- show: isBottom,
399
- fontSize: 11,
400
- backgroundColor: '#475569',
401
- },
402
- },
403
- });
404
- });
405
-
406
- // --- Generate Y-Axes ---
407
- const yAxis: any[] = [];
408
-
409
- // Determine min/max for main Y-axis (respect custom values if provided)
410
- let mainYAxisMin: any;
411
- let mainYAxisMax: any;
412
-
413
- if (options.yAxisMin !== undefined && options.yAxisMin !== 'auto') {
414
- mainYAxisMin = options.yAxisMin;
415
- } else {
416
- mainYAxisMin = AxisUtils.createMinFunction(yAxisPaddingPercent);
417
- }
418
-
419
- if (options.yAxisMax !== undefined && options.yAxisMax !== 'auto') {
420
- mainYAxisMax = options.yAxisMax;
421
- } else {
422
- mainYAxisMax = AxisUtils.createMaxFunction(yAxisPaddingPercent);
423
- }
424
-
425
- // Main Y-Axis (for candlesticks)
426
- yAxis.push({
427
- position: 'right',
428
- scale: true,
429
- min: mainYAxisMin,
430
- max: mainYAxisMax,
431
- gridIndex: 0,
432
- splitLine: {
433
- show: !isMainCollapsed,
434
- lineStyle: { color: '#334155', opacity: 0.5 },
435
- },
436
- axisLine: { show: !isMainCollapsed, lineStyle: { color: '#334155' } },
437
- axisLabel: {
438
- show: !isMainCollapsed,
439
- color: '#94a3b8',
440
- fontFamily: options.fontFamily || 'sans-serif',
441
- formatter: (value: number) => {
442
- if (options.yAxisLabelFormatter) {
443
- return options.yAxisLabelFormatter(value);
444
- }
445
- const decimals = options.yAxisDecimalPlaces !== undefined
446
- ? options.yAxisDecimalPlaces
447
- : AxisUtils.autoDetectDecimals(marketData as OHLCV[]);
448
- return AxisUtils.formatValue(value, decimals);
449
- },
450
- },
451
- });
452
-
453
- // Create separate Y-axes for overlay plots that are incompatible with price range
454
- // Analyze each PLOT separately, not entire indicators
455
- let nextYAxisIndex = 1;
456
-
457
- // Calculate price range if market data is available
458
- let priceMin = -Infinity;
459
- let priceMax = Infinity;
460
- if (marketData && marketData.length > 0) {
461
- priceMin = Math.min(...marketData.map((d) => d.low));
462
- priceMax = Math.max(...marketData.map((d) => d.high));
463
- }
464
-
465
- // Map to store plot-specific Y-axis assignments (key: "indicatorId::plotName")
466
- const overlayYAxisMap: Map<string, number> = new Map();
467
-
468
- indicators.forEach((indicator, id) => {
469
- if (indicator.paneIndex === 0 && !indicator.collapsed) {
470
- // This is an overlay on the main pane
471
- // Analyze EACH PLOT separately
472
-
473
- if (marketData && marketData.length > 0) {
474
- Object.entries(indicator.plots).forEach(([plotName, plot]) => {
475
- const plotKey = `${id}::${plotName}`;
476
-
477
- // Skip visual-only plot types that should never affect Y-axis scaling
478
- // EXCEPTION: shapes with abovebar/belowbar must stay on main Y-axis
479
- const visualOnlyStyles = ['background', 'barcolor', 'char'];
480
-
481
- // Check if this is a shape with price-relative positioning
482
- const isShapeWithPriceLocation =
483
- plot.options.style === 'shape' && (plot.options.location === 'abovebar' || plot.options.location === 'belowbar');
484
-
485
- if (visualOnlyStyles.includes(plot.options.style)) {
486
- // Assign these to a separate Y-axis so they don't affect price scale
487
- if (!overlayYAxisMap.has(plotKey)) {
488
- overlayYAxisMap.set(plotKey, nextYAxisIndex);
489
- nextYAxisIndex++;
490
- }
491
- return; // Skip further processing for this plot
492
- }
493
-
494
- // If it's a shape but NOT with price-relative positioning, treat as visual-only
495
- if (plot.options.style === 'shape' && !isShapeWithPriceLocation) {
496
- if (!overlayYAxisMap.has(plotKey)) {
497
- overlayYAxisMap.set(plotKey, nextYAxisIndex);
498
- nextYAxisIndex++;
499
- }
500
- return;
501
- }
502
-
503
- const values: number[] = [];
504
-
505
- // Extract values for this specific plot
506
- if (plot.data) {
507
- Object.values(plot.data).forEach((value) => {
508
- if (typeof value === 'number' && !isNaN(value) && isFinite(value)) {
509
- values.push(value);
510
- }
511
- });
512
- }
513
-
514
- if (values.length > 0) {
515
- const plotMin = Math.min(...values);
516
- const plotMax = Math.max(...values);
517
- const plotRange = plotMax - plotMin;
518
- const priceRange = priceMax - priceMin;
519
-
520
- // Check if this plot's range is compatible with price range
521
- // Compatible = within price bounds with similar magnitude
522
- const isWithinBounds = plotMin >= priceMin * 0.5 && plotMax <= priceMax * 1.5;
523
- const hasSimilarMagnitude = plotRange > priceRange * 0.01; // At least 1% of price range
524
-
525
- const isCompatible = isWithinBounds && hasSimilarMagnitude;
526
-
527
- if (!isCompatible) {
528
- // This plot needs its own Y-axis - check if we already assigned one
529
- if (!overlayYAxisMap.has(plotKey)) {
530
- overlayYAxisMap.set(plotKey, nextYAxisIndex);
531
- nextYAxisIndex++;
532
- }
533
- }
534
- // Compatible plots stay on yAxisIndex: 0 (not added to map)
535
- }
536
- });
537
- }
538
- }
539
- });
540
-
541
- // Create Y-axes for incompatible plots
542
- // nextYAxisIndex already incremented in the loop above, so we know how many axes we need
543
- const numOverlayAxes = overlayYAxisMap.size > 0 ? nextYAxisIndex - 1 : 0;
544
-
545
- // Track which overlay axes are for visual-only plots (background, barcolor, etc.)
546
- const visualOnlyAxes = new Set<number>();
547
- overlayYAxisMap.forEach((yAxisIdx, plotKey) => {
548
- // Check if this plot is visual-only by looking at the original indicator
549
- indicators.forEach((indicator) => {
550
- Object.entries(indicator.plots).forEach(([plotName, plot]) => {
551
- const key = `${indicator.id}::${plotName}`;
552
- if (key === plotKey && ['background', 'barcolor', 'char'].includes(plot.options.style)) {
553
- visualOnlyAxes.add(yAxisIdx);
554
- }
555
- });
556
- });
557
- });
558
-
559
- for (let i = 0; i < numOverlayAxes; i++) {
560
- const yAxisIndex = i + 1; // Y-axis indices start at 1 for overlays
561
- const isVisualOnly = visualOnlyAxes.has(yAxisIndex);
562
-
563
- yAxis.push({
564
- position: 'left',
565
- scale: !isVisualOnly, // Disable scaling for visual-only plots
566
- min: isVisualOnly ? 0 : AxisUtils.createMinFunction(yAxisPaddingPercent), // Fixed range for visual plots
567
- max: isVisualOnly ? 1 : AxisUtils.createMaxFunction(yAxisPaddingPercent), // Fixed range for visual plots
568
- gridIndex: 0,
569
- show: false, // Hide the axis visual elements
570
- splitLine: { show: false },
571
- axisLine: { show: false },
572
- axisLabel: { show: false },
573
- });
574
- }
575
-
576
- // Separate Panes Y-Axes (start after overlay axes)
577
- const separatePaneYAxisOffset = nextYAxisIndex;
578
- paneConfigs.forEach((pane, i) => {
579
- yAxis.push({
580
- position: 'right',
581
- scale: true,
582
- min: AxisUtils.createMinFunction(yAxisPaddingPercent),
583
- max: AxisUtils.createMaxFunction(yAxisPaddingPercent),
584
- gridIndex: i + 1,
585
- splitLine: {
586
- show: !pane.isCollapsed,
587
- lineStyle: { color: '#334155', opacity: 0.3 },
588
- },
589
- axisLabel: {
590
- show: !pane.isCollapsed,
591
- color: '#94a3b8',
592
- fontFamily: options.fontFamily || 'sans-serif',
593
- fontSize: 10,
594
- formatter: (value: number) => {
595
- if (options.yAxisLabelFormatter) {
596
- return options.yAxisLabelFormatter(value);
597
- }
598
- const decimals = options.yAxisDecimalPlaces !== undefined
599
- ? options.yAxisDecimalPlaces
600
- : AxisUtils.autoDetectDecimals(marketData as OHLCV[]);
601
- return AxisUtils.formatValue(value, decimals);
602
- },
603
- },
604
- axisLine: { show: !pane.isCollapsed, lineStyle: { color: '#334155' } },
605
- });
606
- });
607
-
608
- // --- Generate DataZoom ---
609
- const dataZoom: any[] = [];
610
- if (dzVisible) {
611
- // Add 'inside' zoom (pan/drag) only if zoomOnTouch is enabled (default true)
612
- const zoomOnTouch = options.dataZoom?.zoomOnTouch ?? true;
613
- if (zoomOnTouch) {
614
- dataZoom.push({
615
- type: 'inside',
616
- xAxisIndex: allXAxisIndices,
617
- start: dzStart,
618
- end: dzEnd,
619
- });
620
- }
621
-
622
- if (dzPosition === 'top') {
623
- dataZoom.push({
624
- type: 'slider',
625
- xAxisIndex: allXAxisIndices,
626
- top: '1%',
627
- height: dzHeight + '%',
628
- start: dzStart,
629
- end: dzEnd,
630
- borderColor: '#334155',
631
- textStyle: { color: '#cbd5e1' },
632
- brushSelect: false,
633
- });
634
- } else {
635
- dataZoom.push({
636
- type: 'slider',
637
- xAxisIndex: allXAxisIndices,
638
- bottom: '1%',
639
- height: dzHeight + '%',
640
- start: dzStart,
641
- end: dzEnd,
642
- borderColor: '#334155',
643
- textStyle: { color: '#cbd5e1' },
644
- brushSelect: false,
645
- });
646
- }
647
- }
648
-
649
- return {
650
- grid,
651
- xAxis,
652
- yAxis,
653
- dataZoom,
654
- paneLayout: paneConfigs,
655
- mainPaneHeight: mainHeightVal,
656
- mainPaneTop,
657
- pixelToPercent,
658
- overlayYAxisMap,
659
- separatePaneYAxisOffset,
660
- };
661
- }
662
-
663
- private static calculateMaximized(
664
- containerHeight: number,
665
- options: QFChartOptions,
666
- targetPaneIndex: number // 0 for main, 1+ for indicators
667
- ): LayoutResult {
668
- return {
669
- grid: [],
670
- xAxis: [],
671
- yAxis: [],
672
- dataZoom: [],
673
- paneLayout: [],
674
- mainPaneHeight: 0,
675
- mainPaneTop: 0,
676
- pixelToPercent: 0,
677
- } as any;
678
- }
679
- }
1
+ import { QFChartOptions, Indicator as IndicatorType, OHLCV } from '../types';
2
+ import { AxisUtils } from '../utils/AxisUtils';
3
+
4
+ export interface PaneConfiguration {
5
+ index: number;
6
+ height: number;
7
+ top: number;
8
+ isCollapsed: boolean;
9
+ indicatorId?: string;
10
+ titleColor?: string;
11
+ controls?: {
12
+ collapse?: boolean;
13
+ maximize?: boolean;
14
+ };
15
+ }
16
+
17
+ export interface LayoutResult {
18
+ grid: any[];
19
+ xAxis: any[];
20
+ yAxis: any[];
21
+ dataZoom: any[];
22
+ paneLayout: PaneConfiguration[];
23
+ mainPaneHeight: number;
24
+ mainPaneTop: number;
25
+ pixelToPercent: number;
26
+ }
27
+
28
+ export class LayoutManager {
29
+ public static calculate(
30
+ containerHeight: number,
31
+ indicators: Map<string, IndicatorType>,
32
+ options: QFChartOptions,
33
+ isMainCollapsed: boolean = false,
34
+ maximizedPaneId: string | null = null,
35
+ marketData?: import('../types').OHLCV[]
36
+ ): LayoutResult & { overlayYAxisMap: Map<string, number>; separatePaneYAxisOffset: number } {
37
+ // Calculate pixelToPercent early for maximized logic
38
+ let pixelToPercent = 0;
39
+ if (containerHeight > 0) {
40
+ pixelToPercent = (1 / containerHeight) * 100;
41
+ }
42
+
43
+ // Get Y-axis padding percentage (default 5%)
44
+ const yAxisPaddingPercent = options.yAxisPadding !== undefined ? options.yAxisPadding : 5;
45
+
46
+ // Identify unique separate panes (indices > 0) and sort them
47
+ const separatePaneIndices = Array.from(indicators.values())
48
+ .map((ind) => ind.paneIndex)
49
+ .filter((idx) => idx > 0)
50
+ .sort((a, b) => a - b)
51
+ .filter((value, index, self) => self.indexOf(value) === index); // Unique
52
+
53
+ const hasSeparatePane = separatePaneIndices.length > 0;
54
+
55
+ // DataZoom Configuration
56
+ const dzVisible = options.dataZoom?.visible ?? true;
57
+ const dzPosition = options.dataZoom?.position ?? 'top';
58
+ const dzHeight = options.dataZoom?.height ?? 6;
59
+ const dzStart = options.dataZoom?.start ?? 0;
60
+ const dzEnd = options.dataZoom?.end ?? 100;
61
+
62
+ // Layout Calculation
63
+ let mainPaneTop = 8;
64
+ let chartAreaBottom = 92; // Default if no dataZoom at bottom
65
+
66
+ // Maximized State Logic
67
+ let maximizeTargetIndex = -1; // -1 = none
68
+
69
+ if (maximizedPaneId) {
70
+ if (maximizedPaneId === 'main') {
71
+ maximizeTargetIndex = 0;
72
+ } else {
73
+ const ind = indicators.get(maximizedPaneId);
74
+ if (ind) {
75
+ maximizeTargetIndex = ind.paneIndex;
76
+ }
77
+ }
78
+ }
79
+
80
+ if (maximizeTargetIndex !== -1) {
81
+ // Special Layout for Maximize
82
+ // We must generate grid/axis definitions for ALL indices to maintain series mapping,
83
+ // but hide the non-maximized ones.
84
+
85
+ const grid: any[] = [];
86
+ const xAxis: any[] = [];
87
+ const yAxis: any[] = [];
88
+ const dataZoom: any[] = []; // Hide slider, keep inside?
89
+
90
+ // DataZoom: keep inside, maybe slider if main?
91
+ // Let's keep strict maximize: Full container.
92
+ // Use defaults for maximize if not available, or preserve logic?
93
+ // The calculateMaximized doesn't use LayoutManager.calculate directly but inline logic.
94
+ // It should probably respect the same zoom?
95
+ // But here we are inside LayoutManager.calculate.
96
+
97
+ const dzStart = options.dataZoom?.start ?? 50;
98
+ const dzEnd = options.dataZoom?.end ?? 100;
99
+
100
+ // Add 'inside' zoom only if zoomOnTouch is enabled (default true)
101
+ const zoomOnTouch = options.dataZoom?.zoomOnTouch ?? true;
102
+ if (zoomOnTouch) {
103
+ dataZoom.push({ type: 'inside', xAxisIndex: 'all', start: dzStart, end: dzEnd, filterMode: 'weakFilter' });
104
+ }
105
+
106
+ // Need to know total panes to iterate
107
+ const maxPaneIndex = hasSeparatePane ? Math.max(...separatePaneIndices) : 0;
108
+
109
+ const paneConfigs: PaneConfiguration[] = []; // For GraphicBuilder title placement
110
+
111
+ // Iterate 0 to maxPaneIndex
112
+ for (let i = 0; i <= maxPaneIndex; i++) {
113
+ const isTarget = i === maximizeTargetIndex;
114
+
115
+ // Grid
116
+ grid.push({
117
+ left: '10%',
118
+ right: '10%',
119
+ top: isTarget ? '5%' : '0%',
120
+ height: isTarget ? '90%' : '0%',
121
+ show: isTarget,
122
+ containLabel: false,
123
+ });
124
+
125
+ // X-Axis
126
+ xAxis.push({
127
+ type: 'category',
128
+ gridIndex: i,
129
+ data: [],
130
+ show: isTarget,
131
+ axisLabel: {
132
+ show: isTarget,
133
+ color: '#94a3b8',
134
+ fontFamily: options.fontFamily,
135
+ },
136
+ axisLine: { show: isTarget, lineStyle: { color: '#334155' } },
137
+ splitLine: {
138
+ show: isTarget,
139
+ lineStyle: { color: '#334155', opacity: 0.5 },
140
+ },
141
+ });
142
+
143
+ // Y-Axis
144
+ // For maximized pane 0 (main), respect custom min/max if provided
145
+ let yMin: any;
146
+ let yMax: any;
147
+
148
+ if (i === 0 && maximizeTargetIndex === 0) {
149
+ // Main pane is maximized, use custom values if provided
150
+ yMin =
151
+ options.yAxisMin !== undefined && options.yAxisMin !== 'auto'
152
+ ? options.yAxisMin
153
+ : AxisUtils.createMinFunction(yAxisPaddingPercent);
154
+ yMax =
155
+ options.yAxisMax !== undefined && options.yAxisMax !== 'auto'
156
+ ? options.yAxisMax
157
+ : AxisUtils.createMaxFunction(yAxisPaddingPercent);
158
+ } else {
159
+ // Separate panes always use dynamic scaling
160
+ yMin = AxisUtils.createMinFunction(yAxisPaddingPercent);
161
+ yMax = AxisUtils.createMaxFunction(yAxisPaddingPercent);
162
+ }
163
+
164
+ yAxis.push({
165
+ position: 'right',
166
+ gridIndex: i,
167
+ show: isTarget,
168
+ scale: true,
169
+ min: yMin,
170
+ max: yMax,
171
+ axisLabel: {
172
+ show: isTarget,
173
+ color: '#94a3b8',
174
+ fontFamily: options.fontFamily,
175
+ formatter: (value: number) => {
176
+ if (options.yAxisLabelFormatter) {
177
+ return options.yAxisLabelFormatter(value);
178
+ }
179
+ const decimals = options.yAxisDecimalPlaces !== undefined
180
+ ? options.yAxisDecimalPlaces
181
+ : AxisUtils.autoDetectDecimals(marketData as OHLCV[]);
182
+ return AxisUtils.formatValue(value, decimals);
183
+ },
184
+ },
185
+ splitLine: {
186
+ show: isTarget,
187
+ lineStyle: { color: '#334155', opacity: 0.5 },
188
+ },
189
+ });
190
+
191
+ // Reconstruct Pane Config for GraphicBuilder
192
+ // We need to return `paneLayout` so GraphicBuilder can draw the Restore button
193
+ if (i > 0) {
194
+ // Find indicator for this pane
195
+ const ind = Array.from(indicators.values()).find((ind) => ind.paneIndex === i);
196
+ if (ind) {
197
+ paneConfigs.push({
198
+ index: i,
199
+ height: isTarget ? 90 : 0,
200
+ top: isTarget ? 5 : 0,
201
+ isCollapsed: false,
202
+ indicatorId: ind.id,
203
+ titleColor: ind.titleColor,
204
+ controls: ind.controls,
205
+ });
206
+ }
207
+ }
208
+ }
209
+
210
+ return {
211
+ grid,
212
+ xAxis,
213
+ yAxis,
214
+ dataZoom,
215
+ paneLayout: paneConfigs,
216
+ mainPaneHeight: maximizeTargetIndex === 0 ? 90 : 0,
217
+ mainPaneTop: maximizeTargetIndex === 0 ? 5 : 0,
218
+ pixelToPercent,
219
+ overlayYAxisMap: new Map(), // No overlays in maximized view
220
+ separatePaneYAxisOffset: 1, // In maximized view, no overlays, so separate panes start at 1
221
+ };
222
+ }
223
+
224
+ if (dzVisible) {
225
+ if (dzPosition === 'top') {
226
+ // DataZoom takes top 0% to dzHeight%
227
+ // Main chart starts below it with a small gap
228
+ mainPaneTop = dzHeight + 4; // dzHeight + 4% gap
229
+ chartAreaBottom = 95; // Use more space at bottom since slider is gone
230
+ } else {
231
+ // DataZoom takes bottom
232
+ // Chart ends at 100 - dzHeight - margin
233
+ chartAreaBottom = 100 - dzHeight - 2;
234
+ mainPaneTop = 8;
235
+ }
236
+ } else {
237
+ // No data zoom
238
+ mainPaneTop = 5;
239
+ chartAreaBottom = 95;
240
+ }
241
+
242
+ // We need to calculate height distribution dynamically to avoid overlap.
243
+ // Calculate gap in percent
244
+ let gapPercent = 5;
245
+ if (containerHeight > 0) {
246
+ gapPercent = (20 / containerHeight) * 100;
247
+ }
248
+
249
+ let mainHeightVal = 75; // Default if no separate pane
250
+
251
+ // Prepare separate panes configuration
252
+ let paneConfigs: PaneConfiguration[] = [];
253
+
254
+ if (hasSeparatePane) {
255
+ // Resolve heights for all separate panes
256
+ // 1. Identify panes and their requested heights
257
+ const panes = separatePaneIndices.map((idx) => {
258
+ const ind = Array.from(indicators.values()).find((i) => i.paneIndex === idx);
259
+ return {
260
+ index: idx,
261
+ requestedHeight: ind?.height,
262
+ isCollapsed: ind?.collapsed ?? false,
263
+ indicatorId: ind?.id,
264
+ titleColor: ind?.titleColor,
265
+ controls: ind?.controls,
266
+ };
267
+ });
268
+
269
+ // 2. Assign actual heights
270
+ // If collapsed, use small fixed height (e.g. 3%)
271
+ const resolvedPanes = panes.map((p) => ({
272
+ ...p,
273
+ height: p.isCollapsed ? 3 : p.requestedHeight !== undefined ? p.requestedHeight : 15,
274
+ }));
275
+
276
+ // 3. Calculate total space needed for indicators
277
+ const totalIndicatorHeight = resolvedPanes.reduce((sum, p) => sum + p.height, 0);
278
+ const totalGaps = resolvedPanes.length * gapPercent;
279
+ const totalBottomSpace = totalIndicatorHeight + totalGaps;
280
+
281
+ // 4. Calculate Main Chart Height
282
+ // Available space = chartAreaBottom - mainPaneTop;
283
+ const totalAvailable = chartAreaBottom - mainPaneTop;
284
+ mainHeightVal = totalAvailable - totalBottomSpace;
285
+
286
+ if (isMainCollapsed) {
287
+ mainHeightVal = 3;
288
+ } else {
289
+ // Safety check: ensure main chart has at least some space (e.g. 20%)
290
+ if (mainHeightVal < 20) {
291
+ mainHeightVal = Math.max(mainHeightVal, 10);
292
+ }
293
+ }
294
+
295
+ // 5. Calculate positions
296
+ let currentTop = mainPaneTop + mainHeightVal + gapPercent;
297
+
298
+ paneConfigs = resolvedPanes.map((p) => {
299
+ const config = {
300
+ index: p.index,
301
+ height: p.height,
302
+ top: currentTop,
303
+ isCollapsed: p.isCollapsed,
304
+ indicatorId: p.indicatorId,
305
+ titleColor: p.titleColor,
306
+ controls: p.controls,
307
+ };
308
+ currentTop += p.height + gapPercent;
309
+ return config;
310
+ });
311
+ } else {
312
+ mainHeightVal = chartAreaBottom - mainPaneTop;
313
+ if (isMainCollapsed) {
314
+ mainHeightVal = 3;
315
+ }
316
+ }
317
+
318
+ // --- Generate Grids ---
319
+ const grid: any[] = [];
320
+ // Main Grid (index 0)
321
+ grid.push({
322
+ left: '10%',
323
+ right: '10%',
324
+ top: mainPaneTop + '%',
325
+ height: mainHeightVal + '%',
326
+ containLabel: false, // We handle margins explicitly
327
+ });
328
+
329
+ // Separate Panes Grids
330
+ paneConfigs.forEach((pane) => {
331
+ grid.push({
332
+ left: '10%',
333
+ right: '10%',
334
+ top: pane.top + '%',
335
+ height: pane.height + '%',
336
+ containLabel: false,
337
+ });
338
+ });
339
+
340
+ // --- Generate X-Axes ---
341
+ const allXAxisIndices = [0, ...paneConfigs.map((_, i) => i + 1)];
342
+ const xAxis: any[] = [];
343
+
344
+ // Main X-Axis
345
+ const isMainBottom = paneConfigs.length === 0;
346
+ xAxis.push({
347
+ type: 'category',
348
+ data: [], // Will be filled by SeriesBuilder or QFChart
349
+ gridIndex: 0,
350
+ scale: true,
351
+ // boundaryGap will be set in QFChart.ts based on padding option
352
+ axisLine: {
353
+ onZero: false,
354
+ show: !isMainCollapsed,
355
+ lineStyle: { color: '#334155' },
356
+ },
357
+ splitLine: {
358
+ show: !isMainCollapsed,
359
+ lineStyle: { color: '#334155', opacity: 0.5 },
360
+ },
361
+ axisLabel: {
362
+ show: !isMainCollapsed,
363
+ color: '#94a3b8',
364
+ fontFamily: options.fontFamily || 'sans-serif',
365
+ formatter: (value: number) => {
366
+ if (options.yAxisLabelFormatter) {
367
+ return options.yAxisLabelFormatter(value);
368
+ }
369
+ const decimals = options.yAxisDecimalPlaces !== undefined
370
+ ? options.yAxisDecimalPlaces
371
+ : AxisUtils.autoDetectDecimals(marketData as OHLCV[]);
372
+ return AxisUtils.formatValue(value, decimals);
373
+ },
374
+ },
375
+ axisTick: { show: !isMainCollapsed },
376
+ axisPointer: {
377
+ label: {
378
+ show: isMainBottom,
379
+ fontSize: 11,
380
+ backgroundColor: '#475569',
381
+ },
382
+ },
383
+ });
384
+
385
+ // Separate Panes X-Axes
386
+ paneConfigs.forEach((pane, i) => {
387
+ const isBottom = i === paneConfigs.length - 1;
388
+ xAxis.push({
389
+ type: 'category',
390
+ gridIndex: i + 1, // 0 is main
391
+ data: [], // Shared data
392
+ axisLabel: { show: false }, // Hide labels on indicator panes
393
+ axisLine: { show: !pane.isCollapsed, lineStyle: { color: '#334155' } },
394
+ axisTick: { show: false },
395
+ splitLine: { show: false },
396
+ axisPointer: {
397
+ label: {
398
+ show: isBottom,
399
+ fontSize: 11,
400
+ backgroundColor: '#475569',
401
+ },
402
+ },
403
+ });
404
+ });
405
+
406
+ // --- Generate Y-Axes ---
407
+ const yAxis: any[] = [];
408
+
409
+ // Determine min/max for main Y-axis (respect custom values if provided)
410
+ let mainYAxisMin: any;
411
+ let mainYAxisMax: any;
412
+
413
+ if (options.yAxisMin !== undefined && options.yAxisMin !== 'auto') {
414
+ mainYAxisMin = options.yAxisMin;
415
+ } else {
416
+ mainYAxisMin = AxisUtils.createMinFunction(yAxisPaddingPercent);
417
+ }
418
+
419
+ if (options.yAxisMax !== undefined && options.yAxisMax !== 'auto') {
420
+ mainYAxisMax = options.yAxisMax;
421
+ } else {
422
+ mainYAxisMax = AxisUtils.createMaxFunction(yAxisPaddingPercent);
423
+ }
424
+
425
+ // Main Y-Axis (for candlesticks)
426
+ yAxis.push({
427
+ position: 'right',
428
+ scale: true,
429
+ min: mainYAxisMin,
430
+ max: mainYAxisMax,
431
+ gridIndex: 0,
432
+ splitLine: {
433
+ show: !isMainCollapsed,
434
+ lineStyle: { color: '#334155', opacity: 0.5 },
435
+ },
436
+ axisLine: { show: !isMainCollapsed, lineStyle: { color: '#334155' } },
437
+ axisLabel: {
438
+ show: !isMainCollapsed,
439
+ color: '#94a3b8',
440
+ fontFamily: options.fontFamily || 'sans-serif',
441
+ formatter: (value: number) => {
442
+ if (options.yAxisLabelFormatter) {
443
+ return options.yAxisLabelFormatter(value);
444
+ }
445
+ const decimals = options.yAxisDecimalPlaces !== undefined
446
+ ? options.yAxisDecimalPlaces
447
+ : AxisUtils.autoDetectDecimals(marketData as OHLCV[]);
448
+ return AxisUtils.formatValue(value, decimals);
449
+ },
450
+ },
451
+ });
452
+
453
+ // Create separate Y-axes for overlay plots that are incompatible with price range
454
+ // Analyze each PLOT separately, not entire indicators
455
+ let nextYAxisIndex = 1;
456
+
457
+ // Calculate price range if market data is available
458
+ let priceMin = -Infinity;
459
+ let priceMax = Infinity;
460
+ if (marketData && marketData.length > 0) {
461
+ priceMin = Math.min(...marketData.map((d) => d.low));
462
+ priceMax = Math.max(...marketData.map((d) => d.high));
463
+ }
464
+
465
+ // Map to store plot-specific Y-axis assignments (key: "indicatorId::plotName")
466
+ const overlayYAxisMap: Map<string, number> = new Map();
467
+
468
+ indicators.forEach((indicator, id) => {
469
+ if (indicator.paneIndex === 0 && !indicator.collapsed) {
470
+ // This is an overlay on the main pane
471
+ // Analyze EACH PLOT separately
472
+
473
+ if (marketData && marketData.length > 0) {
474
+ Object.entries(indicator.plots).forEach(([plotName, plot]) => {
475
+ const plotKey = `${id}::${plotName}`;
476
+
477
+ // Skip visual-only plot types that should never affect Y-axis scaling
478
+ // EXCEPTION: shapes with abovebar/belowbar must stay on main Y-axis
479
+ const visualOnlyStyles = ['background', 'barcolor', 'char'];
480
+
481
+ // Check if this is a shape with price-relative positioning
482
+ const isShapeWithPriceLocation =
483
+ plot.options.style === 'shape' && (plot.options.location === 'abovebar' || plot.options.location === 'AboveBar' || plot.options.location === 'belowbar' || plot.options.location === 'BelowBar');
484
+
485
+ if (visualOnlyStyles.includes(plot.options.style)) {
486
+ // Assign these to a separate Y-axis so they don't affect price scale
487
+ if (!overlayYAxisMap.has(plotKey)) {
488
+ overlayYAxisMap.set(plotKey, nextYAxisIndex);
489
+ nextYAxisIndex++;
490
+ }
491
+ return; // Skip further processing for this plot
492
+ }
493
+
494
+ // If it's a shape but NOT with price-relative positioning, treat as visual-only
495
+ if (plot.options.style === 'shape' && !isShapeWithPriceLocation) {
496
+ if (!overlayYAxisMap.has(plotKey)) {
497
+ overlayYAxisMap.set(plotKey, nextYAxisIndex);
498
+ nextYAxisIndex++;
499
+ }
500
+ return;
501
+ }
502
+
503
+ const values: number[] = [];
504
+
505
+ // Extract values for this specific plot
506
+ if (plot.data) {
507
+ Object.values(plot.data).forEach((value) => {
508
+ if (typeof value === 'number' && !isNaN(value) && isFinite(value)) {
509
+ values.push(value);
510
+ }
511
+ });
512
+ }
513
+
514
+ if (values.length > 0) {
515
+ const plotMin = Math.min(...values);
516
+ const plotMax = Math.max(...values);
517
+ const plotRange = plotMax - plotMin;
518
+ const priceRange = priceMax - priceMin;
519
+
520
+ // Check if this plot's range is compatible with price range
521
+ // Compatible = within price bounds with similar magnitude
522
+ const isWithinBounds = plotMin >= priceMin * 0.5 && plotMax <= priceMax * 1.5;
523
+ const hasSimilarMagnitude = plotRange > priceRange * 0.01; // At least 1% of price range
524
+
525
+ const isCompatible = isWithinBounds && hasSimilarMagnitude;
526
+
527
+ if (!isCompatible) {
528
+ // This plot needs its own Y-axis - check if we already assigned one
529
+ if (!overlayYAxisMap.has(plotKey)) {
530
+ overlayYAxisMap.set(plotKey, nextYAxisIndex);
531
+ nextYAxisIndex++;
532
+ }
533
+ }
534
+ // Compatible plots stay on yAxisIndex: 0 (not added to map)
535
+ }
536
+ });
537
+ }
538
+ }
539
+ });
540
+
541
+ // Create Y-axes for incompatible plots
542
+ // nextYAxisIndex already incremented in the loop above, so we know how many axes we need
543
+ const numOverlayAxes = overlayYAxisMap.size > 0 ? nextYAxisIndex - 1 : 0;
544
+
545
+ // Track which overlay axes are for visual-only plots (background, barcolor, etc.)
546
+ const visualOnlyAxes = new Set<number>();
547
+ overlayYAxisMap.forEach((yAxisIdx, plotKey) => {
548
+ // Check if this plot is visual-only by looking at the original indicator
549
+ indicators.forEach((indicator) => {
550
+ Object.entries(indicator.plots).forEach(([plotName, plot]) => {
551
+ const key = `${indicator.id}::${plotName}`;
552
+ if (key === plotKey && ['background', 'barcolor', 'char'].includes(plot.options.style)) {
553
+ visualOnlyAxes.add(yAxisIdx);
554
+ }
555
+ });
556
+ });
557
+ });
558
+
559
+ for (let i = 0; i < numOverlayAxes; i++) {
560
+ const yAxisIndex = i + 1; // Y-axis indices start at 1 for overlays
561
+ const isVisualOnly = visualOnlyAxes.has(yAxisIndex);
562
+
563
+ yAxis.push({
564
+ position: 'left',
565
+ scale: !isVisualOnly, // Disable scaling for visual-only plots
566
+ min: isVisualOnly ? 0 : AxisUtils.createMinFunction(yAxisPaddingPercent), // Fixed range for visual plots
567
+ max: isVisualOnly ? 1 : AxisUtils.createMaxFunction(yAxisPaddingPercent), // Fixed range for visual plots
568
+ gridIndex: 0,
569
+ show: false, // Hide the axis visual elements
570
+ splitLine: { show: false },
571
+ axisLine: { show: false },
572
+ axisLabel: { show: false },
573
+ });
574
+ }
575
+
576
+ // Separate Panes Y-Axes (start after overlay axes)
577
+ const separatePaneYAxisOffset = nextYAxisIndex;
578
+ paneConfigs.forEach((pane, i) => {
579
+ yAxis.push({
580
+ position: 'right',
581
+ scale: true,
582
+ min: AxisUtils.createMinFunction(yAxisPaddingPercent),
583
+ max: AxisUtils.createMaxFunction(yAxisPaddingPercent),
584
+ gridIndex: i + 1,
585
+ splitLine: {
586
+ show: !pane.isCollapsed,
587
+ lineStyle: { color: '#334155', opacity: 0.3 },
588
+ },
589
+ axisLabel: {
590
+ show: !pane.isCollapsed,
591
+ color: '#94a3b8',
592
+ fontFamily: options.fontFamily || 'sans-serif',
593
+ fontSize: 10,
594
+ formatter: (value: number) => {
595
+ if (options.yAxisLabelFormatter) {
596
+ return options.yAxisLabelFormatter(value);
597
+ }
598
+ const decimals = options.yAxisDecimalPlaces !== undefined
599
+ ? options.yAxisDecimalPlaces
600
+ : AxisUtils.autoDetectDecimals(marketData as OHLCV[]);
601
+ return AxisUtils.formatValue(value, decimals);
602
+ },
603
+ },
604
+ axisLine: { show: !pane.isCollapsed, lineStyle: { color: '#334155' } },
605
+ });
606
+ });
607
+
608
+ // --- Generate DataZoom ---
609
+ const dataZoom: any[] = [];
610
+ if (dzVisible) {
611
+ // Add 'inside' zoom (pan/drag) only if zoomOnTouch is enabled (default true)
612
+ const zoomOnTouch = options.dataZoom?.zoomOnTouch ?? true;
613
+ if (zoomOnTouch) {
614
+ dataZoom.push({
615
+ type: 'inside',
616
+ xAxisIndex: allXAxisIndices,
617
+ start: dzStart,
618
+ end: dzEnd,
619
+ filterMode: 'weakFilter',
620
+ });
621
+ }
622
+
623
+ if (dzPosition === 'top') {
624
+ dataZoom.push({
625
+ type: 'slider',
626
+ xAxisIndex: allXAxisIndices,
627
+ top: '1%',
628
+ height: dzHeight + '%',
629
+ start: dzStart,
630
+ end: dzEnd,
631
+ borderColor: '#334155',
632
+ textStyle: { color: '#cbd5e1' },
633
+ brushSelect: false,
634
+ filterMode: 'weakFilter',
635
+ });
636
+ } else {
637
+ dataZoom.push({
638
+ type: 'slider',
639
+ xAxisIndex: allXAxisIndices,
640
+ bottom: '1%',
641
+ height: dzHeight + '%',
642
+ start: dzStart,
643
+ end: dzEnd,
644
+ borderColor: '#334155',
645
+ textStyle: { color: '#cbd5e1' },
646
+ brushSelect: false,
647
+ filterMode: 'weakFilter',
648
+ });
649
+ }
650
+ }
651
+
652
+ return {
653
+ grid,
654
+ xAxis,
655
+ yAxis,
656
+ dataZoom,
657
+ paneLayout: paneConfigs,
658
+ mainPaneHeight: mainHeightVal,
659
+ mainPaneTop,
660
+ pixelToPercent,
661
+ overlayYAxisMap,
662
+ separatePaneYAxisOffset,
663
+ };
664
+ }
665
+
666
+ private static calculateMaximized(
667
+ containerHeight: number,
668
+ options: QFChartOptions,
669
+ targetPaneIndex: number // 0 for main, 1+ for indicators
670
+ ): LayoutResult {
671
+ return {
672
+ grid: [],
673
+ xAxis: [],
674
+ yAxis: [],
675
+ dataZoom: [],
676
+ paneLayout: [],
677
+ mainPaneHeight: 0,
678
+ mainPaneTop: 0,
679
+ pixelToPercent: 0,
680
+ } as any;
681
+ }
682
+ }