@qfo/qfchart 0.6.5 → 0.6.7

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,4 +1,4 @@
1
- import { QFChartOptions, Indicator as IndicatorType } from '../types';
1
+ import { QFChartOptions, Indicator as IndicatorType, OHLCV } from '../types';
2
2
  import { AxisUtils } from '../utils/AxisUtils';
3
3
 
4
4
  export interface PaneConfiguration {
@@ -176,11 +176,10 @@ export class LayoutManager {
176
176
  if (options.yAxisLabelFormatter) {
177
177
  return options.yAxisLabelFormatter(value);
178
178
  }
179
- const decimals = options.yAxisDecimalPlaces !== undefined ? options.yAxisDecimalPlaces : 2;
180
- if (typeof value === 'number') {
181
- return value.toFixed(decimals);
182
- }
183
- return String(value);
179
+ const decimals = options.yAxisDecimalPlaces !== undefined
180
+ ? options.yAxisDecimalPlaces
181
+ : AxisUtils.autoDetectDecimals(marketData as OHLCV[]);
182
+ return AxisUtils.formatValue(value, decimals);
184
183
  },
185
184
  },
186
185
  splitLine: {
@@ -367,11 +366,10 @@ export class LayoutManager {
367
366
  if (options.yAxisLabelFormatter) {
368
367
  return options.yAxisLabelFormatter(value);
369
368
  }
370
- const decimals = options.yAxisDecimalPlaces !== undefined ? options.yAxisDecimalPlaces : 2;
371
- if (typeof value === 'number') {
372
- return value.toFixed(decimals);
373
- }
374
- return String(value);
369
+ const decimals = options.yAxisDecimalPlaces !== undefined
370
+ ? options.yAxisDecimalPlaces
371
+ : AxisUtils.autoDetectDecimals(marketData as OHLCV[]);
372
+ return AxisUtils.formatValue(value, decimals);
375
373
  },
376
374
  },
377
375
  axisTick: { show: !isMainCollapsed },
@@ -444,11 +442,10 @@ export class LayoutManager {
444
442
  if (options.yAxisLabelFormatter) {
445
443
  return options.yAxisLabelFormatter(value);
446
444
  }
447
- const decimals = options.yAxisDecimalPlaces !== undefined ? options.yAxisDecimalPlaces : 2;
448
- if (typeof value === 'number') {
449
- return value.toFixed(decimals);
450
- }
451
- return String(value);
445
+ const decimals = options.yAxisDecimalPlaces !== undefined
446
+ ? options.yAxisDecimalPlaces
447
+ : AxisUtils.autoDetectDecimals(marketData as OHLCV[]);
448
+ return AxisUtils.formatValue(value, decimals);
452
449
  },
453
450
  },
454
451
  });
@@ -544,12 +541,30 @@ export class LayoutManager {
544
541
  // Create Y-axes for incompatible plots
545
542
  // nextYAxisIndex already incremented in the loop above, so we know how many axes we need
546
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
+
547
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
+
548
563
  yAxis.push({
549
564
  position: 'left',
550
- scale: true,
551
- min: AxisUtils.createMinFunction(yAxisPaddingPercent),
552
- max: AxisUtils.createMaxFunction(yAxisPaddingPercent),
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
553
568
  gridIndex: 0,
554
569
  show: false, // Hide the axis visual elements
555
570
  splitLine: { show: false },
@@ -580,11 +595,10 @@ export class LayoutManager {
580
595
  if (options.yAxisLabelFormatter) {
581
596
  return options.yAxisLabelFormatter(value);
582
597
  }
583
- const decimals = options.yAxisDecimalPlaces !== undefined ? options.yAxisDecimalPlaces : 2;
584
- if (typeof value === 'number') {
585
- return value.toFixed(decimals);
586
- }
587
- return String(value);
598
+ const decimals = options.yAxisDecimalPlaces !== undefined
599
+ ? options.yAxisDecimalPlaces
600
+ : AxisUtils.autoDetectDecimals(marketData as OHLCV[]);
601
+ return AxisUtils.formatValue(value, decimals);
588
602
  },
589
603
  },
590
604
  axisLine: { show: !pane.isCollapsed, lineStyle: { color: '#334155' } },
@@ -1,6 +1,7 @@
1
1
  import { OHLCV, Indicator as IndicatorType, QFChartOptions, IndicatorPlot, IndicatorStyle } from '../types';
2
2
  import { PaneConfiguration } from './LayoutManager';
3
3
  import { SeriesRendererFactory } from './SeriesRendererFactory';
4
+ import { AxisUtils } from '../utils/AxisUtils';
4
5
 
5
6
  export class SeriesBuilder {
6
7
  private static readonly DEFAULT_COLOR = '#2962ff';
@@ -32,8 +33,13 @@ export class SeriesBuilder {
32
33
  if (lineStyleType.startsWith('linestyle_')) {
33
34
  lineStyleType = lineStyleType.replace('linestyle_', '') as any;
34
35
  }
36
+ const decimals = options.yAxisDecimalPlaces !== undefined
37
+ ? options.yAxisDecimalPlaces
38
+ : AxisUtils.autoDetectDecimals(marketData);
39
+
35
40
  markLine = {
36
41
  symbol: ['none', 'none'],
42
+ precision: decimals, // Ensure line position is precise enough for small values
37
43
  data: [
38
44
  {
39
45
  yAxis: lastClose,
@@ -45,8 +51,7 @@ export class SeriesBuilder {
45
51
  if (options.yAxisLabelFormatter) {
46
52
  return options.yAxisLabelFormatter(params.value);
47
53
  }
48
- const decimals = options.yAxisDecimalPlaces !== undefined ? options.yAxisDecimalPlaces : 2;
49
- return typeof params.value === 'number' ? params.value.toFixed(decimals) : params.value;
54
+ return AxisUtils.formatValue(params.value, decimals);
50
55
  },
51
56
  color: '#fff',
52
57
  backgroundColor: lineColor,
@@ -125,8 +130,11 @@ export class SeriesBuilder {
125
130
  let yAxisIndex = 0;
126
131
 
127
132
  // Check plot-level overlay setting (overrides indicator-level setting)
133
+ // IMPORTANT: If indicator is overlay (paneIndex === 0), treat all plots as overlays
134
+ // This allows visual-only plots (background, barcolor) to have separate Y-axes while
135
+ // still being on the main chart pane
128
136
  const plotOverlay = plot.options.overlay;
129
- const isPlotOverlay = plotOverlay !== undefined ? plotOverlay : indicator.paneIndex === 0;
137
+ const isPlotOverlay = indicator.paneIndex === 0 || plotOverlay === true;
130
138
 
131
139
  if (isPlotOverlay) {
132
140
  // Plot should be on main chart (overlay)
@@ -1,36 +1,38 @@
1
- import { SeriesRenderer } from './renderers/SeriesRenderer';
2
- import { LineRenderer } from './renderers/LineRenderer';
3
- import { StepRenderer } from './renderers/StepRenderer';
4
- import { HistogramRenderer } from './renderers/HistogramRenderer';
5
- import { ScatterRenderer } from './renderers/ScatterRenderer';
6
- import { OHLCBarRenderer } from './renderers/OHLCBarRenderer';
7
- import { ShapeRenderer } from './renderers/ShapeRenderer';
8
- import { BackgroundRenderer } from './renderers/BackgroundRenderer';
9
- import { FillRenderer } from './renderers/FillRenderer';
10
-
11
- export class SeriesRendererFactory {
12
- private static renderers: Map<string, SeriesRenderer> = new Map();
13
-
14
- static {
15
- this.register('line', new LineRenderer());
16
- this.register('step', new StepRenderer());
17
- this.register('histogram', new HistogramRenderer());
18
- this.register('columns', new HistogramRenderer());
19
- this.register('circles', new ScatterRenderer());
20
- this.register('cross', new ScatterRenderer());
21
- this.register('char', new ScatterRenderer());
22
- this.register('bar', new OHLCBarRenderer());
23
- this.register('candle', new OHLCBarRenderer());
24
- this.register('shape', new ShapeRenderer());
25
- this.register('background', new BackgroundRenderer());
26
- this.register('fill', new FillRenderer());
27
- }
28
-
29
- public static register(style: string, renderer: SeriesRenderer) {
30
- this.renderers.set(style, renderer);
31
- }
32
-
33
- public static get(style: string): SeriesRenderer {
34
- return this.renderers.get(style) || this.renderers.get('line')!; // Default to line
35
- }
36
- }
1
+ import { SeriesRenderer } from './renderers/SeriesRenderer';
2
+ import { LineRenderer } from './renderers/LineRenderer';
3
+ import { StepRenderer } from './renderers/StepRenderer';
4
+ import { HistogramRenderer } from './renderers/HistogramRenderer';
5
+ import { ScatterRenderer } from './renderers/ScatterRenderer';
6
+ import { OHLCBarRenderer } from './renderers/OHLCBarRenderer';
7
+ import { ShapeRenderer } from './renderers/ShapeRenderer';
8
+ import { BackgroundRenderer } from './renderers/BackgroundRenderer';
9
+ import { FillRenderer } from './renderers/FillRenderer';
10
+ import { LabelRenderer } from './renderers/LabelRenderer';
11
+
12
+ export class SeriesRendererFactory {
13
+ private static renderers: Map<string, SeriesRenderer> = new Map();
14
+
15
+ static {
16
+ this.register('line', new LineRenderer());
17
+ this.register('step', new StepRenderer());
18
+ this.register('histogram', new HistogramRenderer());
19
+ this.register('columns', new HistogramRenderer());
20
+ this.register('circles', new ScatterRenderer());
21
+ this.register('cross', new ScatterRenderer());
22
+ this.register('char', new ScatterRenderer());
23
+ this.register('bar', new OHLCBarRenderer());
24
+ this.register('candle', new OHLCBarRenderer());
25
+ this.register('shape', new ShapeRenderer());
26
+ this.register('background', new BackgroundRenderer());
27
+ this.register('fill', new FillRenderer());
28
+ this.register('label', new LabelRenderer());
29
+ }
30
+
31
+ public static register(style: string, renderer: SeriesRenderer) {
32
+ this.renderers.set(style, renderer);
33
+ }
34
+
35
+ public static get(style: string): SeriesRenderer {
36
+ return this.renderers.get(style) || this.renderers.get('line')!; // Default to line
37
+ }
38
+ }
@@ -14,7 +14,7 @@ export class BackgroundRenderer implements SeriesRenderer {
14
14
  const xVal = api.value(0);
15
15
  if (isNaN(xVal)) return;
16
16
 
17
- const start = api.coord([xVal, 0]);
17
+ const start = api.coord([xVal, 0.5]); // Use 0.5 as a fixed Y-value within [0,1] range
18
18
  const size = api.size([1, 0]);
19
19
  const width = size[0];
20
20
  const sys = params.coordSys;
@@ -39,7 +39,9 @@ export class BackgroundRenderer implements SeriesRenderer {
39
39
  silent: true,
40
40
  };
41
41
  },
42
- data: dataArray.map((val, i) => [i, val]),
42
+ // Normalize data values to 0.5 (middle of [0,1] range) to prevent Y-axis scaling issues
43
+ // The actual value is only used to check if the background should render (non-null/non-NaN)
44
+ data: dataArray.map((val, i) => [i, val !== null && val !== undefined && !isNaN(val) ? 0.5 : null]),
43
45
  };
44
46
  }
45
47
  }
@@ -0,0 +1,231 @@
1
+ import { SeriesRenderer, RenderContext } from './SeriesRenderer';
2
+ import { ShapeUtils } from '../../utils/ShapeUtils';
3
+
4
+ export class LabelRenderer implements SeriesRenderer {
5
+ render(context: RenderContext): any {
6
+ const { seriesName, xAxisIndex, yAxisIndex, dataArray, candlestickData } = context;
7
+
8
+ const labelData = dataArray
9
+ .map((val, i) => {
10
+ if (val === null || val === undefined) return null;
11
+
12
+ // val is a label object: {id, x, y, text, xloc, yloc, color, style, textcolor, size, textalign, tooltip}
13
+ const lbl = typeof val === 'object' ? val : null;
14
+ if (!lbl) return null;
15
+
16
+ const text = lbl.text || '';
17
+ const color = lbl.color || '#2962ff';
18
+ const textcolor = lbl.textcolor || '#ffffff';
19
+ const yloc = lbl.yloc || 'price';
20
+ const styleRaw = lbl.style || 'style_label_down';
21
+ const size = lbl.size || 'normal';
22
+ const textalign = lbl.textalign || 'align_center';
23
+ const tooltip = lbl.tooltip || '';
24
+
25
+ // Map Pine style string to shape name for ShapeUtils
26
+ const shape = this.styleToShape(styleRaw);
27
+
28
+ // Determine Y value based on yloc
29
+ let yValue = lbl.y;
30
+ let symbolOffset: (string | number)[] = [0, 0];
31
+
32
+ if (yloc === 'abovebar') {
33
+ if (candlestickData && candlestickData[i]) {
34
+ yValue = candlestickData[i].high;
35
+ }
36
+ symbolOffset = [0, '-150%'];
37
+ } else if (yloc === 'belowbar') {
38
+ if (candlestickData && candlestickData[i]) {
39
+ yValue = candlestickData[i].low;
40
+ }
41
+ symbolOffset = [0, '150%'];
42
+ }
43
+
44
+ // Get symbol from ShapeUtils
45
+ const symbol = ShapeUtils.getShapeSymbol(shape);
46
+ const symbolSize = ShapeUtils.getShapeSize(size);
47
+
48
+ // Compute font size for this label
49
+ const fontSize = this.getSizePx(size);
50
+
51
+ // Dynamically size the bubble to fit text content
52
+ let finalSize: number | number[];
53
+ if (shape === 'labeldown' || shape === 'labelup') {
54
+ // Approximate text width: chars * fontSize * avgCharWidthRatio (bold)
55
+ const textWidth = text.length * fontSize * 0.65;
56
+ const minWidth = fontSize * 2.5;
57
+ const bubbleWidth = Math.max(minWidth, textWidth + fontSize * 1.6);
58
+ const bubbleHeight = fontSize * 2.8;
59
+ finalSize = [bubbleWidth, bubbleHeight];
60
+
61
+ // Offset bubble so the pointer tip sits at the anchor price.
62
+ // The SVG path pointer is ~20% of total height.
63
+ if (shape === 'labeldown') {
64
+ symbolOffset = [symbolOffset[0], typeof symbolOffset[1] === 'string'
65
+ ? symbolOffset[1]
66
+ : (symbolOffset[1] as number) - bubbleHeight * 0.35];
67
+ } else {
68
+ symbolOffset = [symbolOffset[0], typeof symbolOffset[1] === 'string'
69
+ ? symbolOffset[1]
70
+ : (symbolOffset[1] as number) + bubbleHeight * 0.35];
71
+ }
72
+ } else if (shape === 'none') {
73
+ finalSize = 0;
74
+ } else {
75
+ if (Array.isArray(symbolSize)) {
76
+ finalSize = [symbolSize[0] * 1.5, symbolSize[1] * 1.5];
77
+ } else {
78
+ finalSize = symbolSize * 1.5;
79
+ }
80
+ }
81
+
82
+ // Determine label position based on style direction
83
+ const labelPosition = this.getLabelPosition(styleRaw, yloc);
84
+ const isInsideLabel = labelPosition === 'inside' ||
85
+ labelPosition.startsWith('inside');
86
+
87
+ const item: any = {
88
+ value: [i, yValue],
89
+ symbol: symbol,
90
+ symbolSize: finalSize,
91
+ symbolOffset: symbolOffset,
92
+ itemStyle: {
93
+ color: color,
94
+ },
95
+ label: {
96
+ show: !!text,
97
+ position: labelPosition,
98
+ distance: isInsideLabel ? 0 : 5,
99
+ formatter: text,
100
+ color: textcolor,
101
+ fontSize: fontSize,
102
+ fontWeight: 'bold',
103
+ align: isInsideLabel ? 'center'
104
+ : textalign === 'align_left' ? 'left'
105
+ : textalign === 'align_right' ? 'right'
106
+ : 'center',
107
+ verticalAlign: 'middle',
108
+ padding: [2, 6],
109
+ },
110
+ };
111
+
112
+ if (tooltip) {
113
+ item.tooltip = { formatter: tooltip };
114
+ }
115
+
116
+ return item;
117
+ })
118
+ .filter((item) => item !== null);
119
+
120
+ return {
121
+ name: seriesName,
122
+ type: 'scatter',
123
+ xAxisIndex: xAxisIndex,
124
+ yAxisIndex: yAxisIndex,
125
+ data: labelData,
126
+ z: 20,
127
+ };
128
+ }
129
+
130
+ private styleToShape(style: string): string {
131
+ // Strip 'style_' prefix
132
+ const s = style.startsWith('style_') ? style.substring(6) : style;
133
+
134
+ switch (s) {
135
+ case 'label_down':
136
+ return 'labeldown';
137
+ case 'label_up':
138
+ return 'labelup';
139
+ case 'label_left':
140
+ return 'labeldown'; // Use labeldown shape, position text left
141
+ case 'label_right':
142
+ return 'labeldown'; // Use labeldown shape, position text right
143
+ case 'label_lower_left':
144
+ return 'labeldown';
145
+ case 'label_lower_right':
146
+ return 'labeldown';
147
+ case 'label_upper_left':
148
+ return 'labelup';
149
+ case 'label_upper_right':
150
+ return 'labelup';
151
+ case 'label_center':
152
+ return 'labeldown';
153
+ case 'circle':
154
+ return 'circle';
155
+ case 'square':
156
+ return 'square';
157
+ case 'diamond':
158
+ return 'diamond';
159
+ case 'flag':
160
+ return 'flag';
161
+ case 'arrowup':
162
+ return 'arrowup';
163
+ case 'arrowdown':
164
+ return 'arrowdown';
165
+ case 'cross':
166
+ return 'cross';
167
+ case 'xcross':
168
+ return 'xcross';
169
+ case 'triangleup':
170
+ return 'triangleup';
171
+ case 'triangledown':
172
+ return 'triangledown';
173
+ case 'text_outline':
174
+ return 'none';
175
+ case 'none':
176
+ return 'none';
177
+ default:
178
+ return 'labeldown';
179
+ }
180
+ }
181
+
182
+ private getLabelPosition(style: string, yloc: string): string {
183
+ const s = style.startsWith('style_') ? style.substring(6) : style;
184
+
185
+ switch (s) {
186
+ case 'label_down':
187
+ return 'inside';
188
+ case 'label_up':
189
+ return 'inside';
190
+ case 'label_left':
191
+ return 'left';
192
+ case 'label_right':
193
+ return 'right';
194
+ case 'label_lower_left':
195
+ return 'insideBottomLeft';
196
+ case 'label_lower_right':
197
+ return 'insideBottomRight';
198
+ case 'label_upper_left':
199
+ return 'insideTopLeft';
200
+ case 'label_upper_right':
201
+ return 'insideTopRight';
202
+ case 'label_center':
203
+ return 'inside';
204
+ case 'text_outline':
205
+ case 'none':
206
+ // Text only, positioned based on yloc
207
+ return yloc === 'abovebar' ? 'top' : yloc === 'belowbar' ? 'bottom' : 'top';
208
+ default:
209
+ // For simple shapes (circle, diamond, etc.), text goes outside
210
+ return yloc === 'belowbar' ? 'bottom' : 'top';
211
+ }
212
+ }
213
+
214
+ private getSizePx(size: string): number {
215
+ switch (size) {
216
+ case 'tiny':
217
+ return 8;
218
+ case 'small':
219
+ return 9;
220
+ case 'normal':
221
+ case 'auto':
222
+ return 10;
223
+ case 'large':
224
+ return 12;
225
+ case 'huge':
226
+ return 14;
227
+ default:
228
+ return 10;
229
+ }
230
+ }
231
+ }