@qfo/qfchart 0.6.7 → 0.6.8

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.
@@ -0,0 +1,188 @@
1
+ import { SeriesRenderer, RenderContext } from './SeriesRenderer';
2
+
3
+ /**
4
+ * Renderer for Pine Script line.* drawing objects.
5
+ * Each line is defined by two endpoints (x1,y1) → (x2,y2) with optional
6
+ * extend, dash style, and arrow heads.
7
+ *
8
+ * Style name: 'drawing_line' (distinct from 'line' used by plot()).
9
+ */
10
+ export class DrawingLineRenderer implements SeriesRenderer {
11
+ render(context: RenderContext): any {
12
+ const { seriesName, xAxisIndex, yAxisIndex, dataArray, dataIndexOffset } = context;
13
+ const offset = dataIndexOffset || 0;
14
+ const defaultColor = '#2962ff';
15
+
16
+ // Collect all non-null, non-deleted line objects from the sparse dataArray.
17
+ // Drawing objects are stored as an array of all lines in a single data entry
18
+ // (since multiple objects at the same bar would overwrite each other in the
19
+ // sparse array). Handle both array-of-objects and single-object entries.
20
+ const lineObjects: any[] = [];
21
+ const lineData: number[][] = [];
22
+
23
+ for (let i = 0; i < dataArray.length; i++) {
24
+ const val = dataArray[i];
25
+ if (!val) continue;
26
+
27
+ const items = Array.isArray(val) ? val : [val];
28
+ for (const ln of items) {
29
+ if (ln && typeof ln === 'object' && !ln._deleted) {
30
+ lineObjects.push(ln);
31
+ // Apply padding offset for bar_index-based coordinates
32
+ const xOff = ln.xloc === 'bar_index' ? offset : 0;
33
+ lineData.push([ln.x1 + xOff, ln.y1, ln.x2 + xOff, ln.y2]);
34
+ }
35
+ }
36
+ }
37
+
38
+ if (lineData.length === 0) {
39
+ return { name: seriesName, type: 'custom', xAxisIndex, yAxisIndex, data: [], silent: true };
40
+ }
41
+
42
+ return {
43
+ name: seriesName,
44
+ type: 'custom',
45
+ xAxisIndex,
46
+ yAxisIndex,
47
+ renderItem: (params: any, api: any) => {
48
+ const idx = params.dataIndex;
49
+ const ln = lineObjects[idx];
50
+ if (!ln || ln._deleted) return;
51
+
52
+ const x1 = api.value(0);
53
+ const y1 = api.value(1);
54
+ const x2 = api.value(2);
55
+ const y2 = api.value(3);
56
+
57
+ let p1 = api.coord([x1, y1]);
58
+ let p2 = api.coord([x2, y2]);
59
+
60
+ // Handle extend (none | left | right | both)
61
+ const extend = ln.extend || 'none';
62
+ if (extend !== 'none') {
63
+ const cs = params.coordSys;
64
+ const left = cs.x;
65
+ const right = cs.x + cs.width;
66
+ const top = cs.y;
67
+ const bottom = cs.y + cs.height;
68
+ [p1, p2] = this.extendLine(p1, p2, extend, left, right, top, bottom);
69
+ }
70
+
71
+ const children: any[] = [];
72
+ const color = ln.color || defaultColor;
73
+ const lineWidth = ln.width || 1;
74
+
75
+ // Main line segment
76
+ children.push({
77
+ type: 'line',
78
+ shape: { x1: p1[0], y1: p1[1], x2: p2[0], y2: p2[1] },
79
+ style: {
80
+ stroke: color,
81
+ lineWidth: lineWidth,
82
+ lineDash: this.getDashPattern(ln.style),
83
+ },
84
+ });
85
+
86
+ // Arrow heads based on style
87
+ const style = ln.style || 'style_solid';
88
+ if (style === 'style_arrow_left' || style === 'style_arrow_both') {
89
+ const arrow = this.arrowHead(p2, p1, lineWidth, color);
90
+ if (arrow) children.push(arrow);
91
+ }
92
+ if (style === 'style_arrow_right' || style === 'style_arrow_both') {
93
+ const arrow = this.arrowHead(p1, p2, lineWidth, color);
94
+ if (arrow) children.push(arrow);
95
+ }
96
+
97
+ return { type: 'group', children };
98
+ },
99
+ data: lineData,
100
+ z: 15,
101
+ silent: true,
102
+ emphasis: { disabled: true },
103
+ };
104
+ }
105
+
106
+ private getDashPattern(style: string): number[] | undefined {
107
+ switch (style) {
108
+ case 'style_dotted':
109
+ return [2, 2];
110
+ case 'style_dashed':
111
+ return [6, 4];
112
+ default:
113
+ return undefined;
114
+ }
115
+ }
116
+
117
+ private extendLine(
118
+ p1: number[],
119
+ p2: number[],
120
+ extend: string,
121
+ left: number,
122
+ right: number,
123
+ top: number,
124
+ bottom: number,
125
+ ): [number[], number[]] {
126
+ const dx = p2[0] - p1[0];
127
+ const dy = p2[1] - p1[1];
128
+
129
+ if (dx === 0 && dy === 0) return [p1, p2];
130
+
131
+ const extendPoint = (origin: number[], dir: number[]): number[] => {
132
+ let tMax = Infinity;
133
+ if (dir[0] !== 0) {
134
+ const tx = dir[0] > 0 ? (right - origin[0]) / dir[0] : (left - origin[0]) / dir[0];
135
+ tMax = Math.min(tMax, tx);
136
+ }
137
+ if (dir[1] !== 0) {
138
+ const ty = dir[1] > 0 ? (bottom - origin[1]) / dir[1] : (top - origin[1]) / dir[1];
139
+ tMax = Math.min(tMax, ty);
140
+ }
141
+ if (!isFinite(tMax)) tMax = 0;
142
+ return [origin[0] + tMax * dir[0], origin[1] + tMax * dir[1]];
143
+ };
144
+
145
+ let newP1 = p1;
146
+ let newP2 = p2;
147
+
148
+ if (extend === 'right' || extend === 'both') {
149
+ newP2 = extendPoint(p1, [dx, dy]);
150
+ }
151
+ if (extend === 'left' || extend === 'both') {
152
+ newP1 = extendPoint(p2, [-dx, -dy]);
153
+ }
154
+
155
+ return [newP1, newP2];
156
+ }
157
+
158
+ private arrowHead(from: number[], to: number[], lineWidth: number, color: string): any {
159
+ const dx = to[0] - from[0];
160
+ const dy = to[1] - from[1];
161
+ const len = Math.sqrt(dx * dx + dy * dy);
162
+ if (len < 1) return null;
163
+
164
+ const size = Math.max(8, lineWidth * 4);
165
+ const nx = dx / len;
166
+ const ny = dy / len;
167
+
168
+ // Arrow tip at `to`, base offset back by `size`
169
+ const bx = to[0] - nx * size;
170
+ const by = to[1] - ny * size;
171
+
172
+ // Perpendicular offset for arrowhead width
173
+ const px = -ny * size * 0.4;
174
+ const py = nx * size * 0.4;
175
+
176
+ return {
177
+ type: 'polygon',
178
+ shape: {
179
+ points: [
180
+ [to[0], to[1]],
181
+ [bx + px, by + py],
182
+ [bx - px, by - py],
183
+ ],
184
+ },
185
+ style: { fill: color },
186
+ };
187
+ }
188
+ }
@@ -1,99 +1,99 @@
1
- import { SeriesRenderer, RenderContext } from './SeriesRenderer';
2
- import { ColorUtils } from '../../utils/ColorUtils';
3
-
4
- export class FillRenderer implements SeriesRenderer {
5
- render(context: RenderContext): any {
6
- const { seriesName, xAxisIndex, yAxisIndex, plotOptions, plotDataArrays, indicatorId, plotName } = context;
7
- const totalDataLength = context.dataArray.length; // Use length from dataArray placeholder
8
-
9
- // Fill plots reference other plots to fill the area between them
10
- const plot1Key = plotOptions.plot1 ? `${indicatorId}::${plotOptions.plot1}` : null;
11
- const plot2Key = plotOptions.plot2 ? `${indicatorId}::${plotOptions.plot2}` : null;
12
-
13
- if (!plot1Key || !plot2Key) {
14
- console.warn(`Fill plot "${plotName}" missing plot1 or plot2 reference`);
15
- return null;
16
- }
17
-
18
- const plot1Data = plotDataArrays?.get(plot1Key);
19
- const plot2Data = plotDataArrays?.get(plot2Key);
20
-
21
- if (!plot1Data || !plot2Data) {
22
- console.warn(`Fill plot "${plotName}" references non-existent plots: ${plotOptions.plot1}, ${plotOptions.plot2}`);
23
- return null;
24
- }
25
-
26
- // Parse color to extract opacity
27
- const { color: fillColor, opacity: fillOpacity } = ColorUtils.parseColor(plotOptions.color || 'rgba(128, 128, 128, 0.2)');
28
-
29
- // Create fill data with previous values for smooth polygon rendering
30
- const fillDataWithPrev: any[] = [];
31
- for (let i = 0; i < totalDataLength; i++) {
32
- const y1 = plot1Data[i];
33
- const y2 = plot2Data[i];
34
- const prevY1 = i > 0 ? plot1Data[i - 1] : null;
35
- const prevY2 = i > 0 ? plot2Data[i - 1] : null;
36
-
37
- fillDataWithPrev.push([i, y1, y2, prevY1, prevY2]);
38
- }
39
-
40
- // Add fill series with smooth area rendering
41
- return {
42
- name: seriesName,
43
- type: 'custom',
44
- xAxisIndex: xAxisIndex,
45
- yAxisIndex: yAxisIndex,
46
- z: -5, // Render behind lines but above background
47
- renderItem: (params: any, api: any) => {
48
- const index = params.dataIndex;
49
-
50
- // Skip first point (no previous to connect to)
51
- if (index === 0) return null;
52
-
53
- const y1 = api.value(1); // Current upper
54
- const y2 = api.value(2); // Current lower
55
- const prevY1 = api.value(3); // Previous upper
56
- const prevY2 = api.value(4); // Previous lower
57
-
58
- // Skip if any value is null/NaN
59
- if (
60
- y1 === null ||
61
- y2 === null ||
62
- prevY1 === null ||
63
- prevY2 === null ||
64
- isNaN(y1) ||
65
- isNaN(y2) ||
66
- isNaN(prevY1) ||
67
- isNaN(prevY2)
68
- ) {
69
- return null;
70
- }
71
-
72
- // Get pixel coordinates for all 4 points
73
- const p1Prev = api.coord([index - 1, prevY1]); // Previous upper
74
- const p1Curr = api.coord([index, y1]); // Current upper
75
- const p2Curr = api.coord([index, y2]); // Current lower
76
- const p2Prev = api.coord([index - 1, prevY2]); // Previous lower
77
-
78
- // Create a smooth polygon connecting the segments
79
- return {
80
- type: 'polygon',
81
- shape: {
82
- points: [
83
- p1Prev, // Top-left
84
- p1Curr, // Top-right
85
- p2Curr, // Bottom-right
86
- p2Prev, // Bottom-left
87
- ],
88
- },
89
- style: {
90
- fill: fillColor,
91
- opacity: fillOpacity,
92
- },
93
- silent: true,
94
- };
95
- },
96
- data: fillDataWithPrev,
97
- };
98
- }
99
- }
1
+ import { SeriesRenderer, RenderContext } from './SeriesRenderer';
2
+ import { ColorUtils } from '../../utils/ColorUtils';
3
+
4
+ export class FillRenderer implements SeriesRenderer {
5
+ render(context: RenderContext): any {
6
+ const { seriesName, xAxisIndex, yAxisIndex, plotOptions, plotDataArrays, indicatorId, plotName } = context;
7
+ const totalDataLength = context.dataArray.length; // Use length from dataArray placeholder
8
+
9
+ // Fill plots reference other plots to fill the area between them
10
+ const plot1Key = plotOptions.plot1 ? `${indicatorId}::${plotOptions.plot1}` : null;
11
+ const plot2Key = plotOptions.plot2 ? `${indicatorId}::${plotOptions.plot2}` : null;
12
+
13
+ if (!plot1Key || !plot2Key) {
14
+ console.warn(`Fill plot "${plotName}" missing plot1 or plot2 reference`);
15
+ return null;
16
+ }
17
+
18
+ const plot1Data = plotDataArrays?.get(plot1Key);
19
+ const plot2Data = plotDataArrays?.get(plot2Key);
20
+
21
+ if (!plot1Data || !plot2Data) {
22
+ console.warn(`Fill plot "${plotName}" references non-existent plots: ${plotOptions.plot1}, ${plotOptions.plot2}`);
23
+ return null;
24
+ }
25
+
26
+ // Parse color to extract opacity
27
+ const { color: fillColor, opacity: fillOpacity } = ColorUtils.parseColor(plotOptions.color || 'rgba(128, 128, 128, 0.2)');
28
+
29
+ // Create fill data with previous values for smooth polygon rendering
30
+ const fillDataWithPrev: any[] = [];
31
+ for (let i = 0; i < totalDataLength; i++) {
32
+ const y1 = plot1Data[i];
33
+ const y2 = plot2Data[i];
34
+ const prevY1 = i > 0 ? plot1Data[i - 1] : null;
35
+ const prevY2 = i > 0 ? plot2Data[i - 1] : null;
36
+
37
+ fillDataWithPrev.push([i, y1, y2, prevY1, prevY2]);
38
+ }
39
+
40
+ // Add fill series with smooth area rendering
41
+ return {
42
+ name: seriesName,
43
+ type: 'custom',
44
+ xAxisIndex: xAxisIndex,
45
+ yAxisIndex: yAxisIndex,
46
+ z: 1, // Behind plot lines (z=2) and candles (z=5), above grid background
47
+ renderItem: (params: any, api: any) => {
48
+ const index = params.dataIndex;
49
+
50
+ // Skip first point (no previous to connect to)
51
+ if (index === 0) return null;
52
+
53
+ const y1 = api.value(1); // Current upper
54
+ const y2 = api.value(2); // Current lower
55
+ const prevY1 = api.value(3); // Previous upper
56
+ const prevY2 = api.value(4); // Previous lower
57
+
58
+ // Skip if any value is null/NaN
59
+ if (
60
+ y1 === null ||
61
+ y2 === null ||
62
+ prevY1 === null ||
63
+ prevY2 === null ||
64
+ isNaN(y1) ||
65
+ isNaN(y2) ||
66
+ isNaN(prevY1) ||
67
+ isNaN(prevY2)
68
+ ) {
69
+ return null;
70
+ }
71
+
72
+ // Get pixel coordinates for all 4 points
73
+ const p1Prev = api.coord([index - 1, prevY1]); // Previous upper
74
+ const p1Curr = api.coord([index, y1]); // Current upper
75
+ const p2Curr = api.coord([index, y2]); // Current lower
76
+ const p2Prev = api.coord([index - 1, prevY2]); // Previous lower
77
+
78
+ // Create a smooth polygon connecting the segments
79
+ return {
80
+ type: 'polygon',
81
+ shape: {
82
+ points: [
83
+ p1Prev, // Top-left
84
+ p1Curr, // Top-right
85
+ p2Curr, // Bottom-right
86
+ p2Prev, // Bottom-left
87
+ ],
88
+ },
89
+ style: {
90
+ fill: fillColor,
91
+ opacity: fillOpacity,
92
+ },
93
+ silent: true,
94
+ };
95
+ },
96
+ data: fillDataWithPrev,
97
+ };
98
+ }
99
+ }
@@ -3,16 +3,27 @@ import { ShapeUtils } from '../../utils/ShapeUtils';
3
3
 
4
4
  export class LabelRenderer implements SeriesRenderer {
5
5
  render(context: RenderContext): any {
6
- const { seriesName, xAxisIndex, yAxisIndex, dataArray, candlestickData } = context;
6
+ const { seriesName, xAxisIndex, yAxisIndex, dataArray, candlestickData, dataIndexOffset } = context;
7
+ const offset = dataIndexOffset || 0;
7
8
 
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;
9
+ // Collect all non-null, non-deleted label objects from the sparse dataArray.
10
+ // Drawing objects are stored as an array of all labels in a single data entry
11
+ // (since multiple objects at the same bar would overwrite each other in the
12
+ // sparse array). Handle both array-of-objects and single-object entries.
13
+ const labelObjects: any[] = [];
14
+ for (let i = 0; i < dataArray.length; i++) {
15
+ const val = dataArray[i];
16
+ if (!val) continue;
17
+ const items = Array.isArray(val) ? val : [val];
18
+ for (const lbl of items) {
19
+ if (lbl && typeof lbl === 'object' && !lbl._deleted) {
20
+ labelObjects.push(lbl);
21
+ }
22
+ }
23
+ }
15
24
 
25
+ const labelData = labelObjects
26
+ .map((lbl) => {
16
27
  const text = lbl.text || '';
17
28
  const color = lbl.color || '#2962ff';
18
29
  const textcolor = lbl.textcolor || '#ffffff';
@@ -25,18 +36,21 @@ export class LabelRenderer implements SeriesRenderer {
25
36
  // Map Pine style string to shape name for ShapeUtils
26
37
  const shape = this.styleToShape(styleRaw);
27
38
 
39
+ // Determine X position using label's own x coordinate
40
+ const xPos = lbl.xloc === 'bar_index' ? (lbl.x + offset) : lbl.x;
41
+
28
42
  // Determine Y value based on yloc
29
43
  let yValue = lbl.y;
30
44
  let symbolOffset: (string | number)[] = [0, 0];
31
45
 
32
46
  if (yloc === 'abovebar') {
33
- if (candlestickData && candlestickData[i]) {
34
- yValue = candlestickData[i].high;
47
+ if (candlestickData && candlestickData[xPos]) {
48
+ yValue = candlestickData[xPos].high;
35
49
  }
36
50
  symbolOffset = [0, '-150%'];
37
51
  } else if (yloc === 'belowbar') {
38
- if (candlestickData && candlestickData[i]) {
39
- yValue = candlestickData[i].low;
52
+ if (candlestickData && candlestickData[xPos]) {
53
+ yValue = candlestickData[xPos].low;
40
54
  }
41
55
  symbolOffset = [0, '150%'];
42
56
  }
@@ -50,24 +64,58 @@ export class LabelRenderer implements SeriesRenderer {
50
64
 
51
65
  // Dynamically size the bubble to fit text content
52
66
  let finalSize: number | number[];
53
- if (shape === 'labeldown' || shape === 'labelup') {
67
+ const isBubble = shape === 'labeldown' || shape === 'labelup' ||
68
+ shape === 'labelleft' || shape === 'labelright';
69
+ // Track label text offset for centering text within the body
70
+ // (excluding the pointer area)
71
+ let labelTextOffset: [number, number] = [0, 0];
72
+
73
+ if (isBubble) {
54
74
  // Approximate text width: chars * fontSize * avgCharWidthRatio (bold)
55
75
  const textWidth = text.length * fontSize * 0.65;
56
76
  const minWidth = fontSize * 2.5;
57
77
  const bubbleWidth = Math.max(minWidth, textWidth + fontSize * 1.6);
58
78
  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];
79
+
80
+ // SVG pointer takes 3/24 = 12.5% of the path dimension
81
+ const pointerRatio = 3 / 24;
82
+
83
+ if (shape === 'labelleft' || shape === 'labelright') {
84
+ // Add extra width for the pointer
85
+ const totalWidth = bubbleWidth / (1 - pointerRatio);
86
+ finalSize = [totalWidth, bubbleHeight];
87
+
88
+ // Offset so the pointer tip sits at the anchor x position.
89
+ const xOff = typeof symbolOffset[0] === 'string' ? 0
90
+ : (symbolOffset[0] as number);
91
+ if (shape === 'labelleft') {
92
+ // Pointer on left → shift bubble body to the right
93
+ symbolOffset = [xOff + totalWidth * 0.42, symbolOffset[1]];
94
+ // Shift text right to center within body (not pointer)
95
+ labelTextOffset = [totalWidth * pointerRatio * 0.5, 0];
96
+ } else {
97
+ // Pointer on right → shift bubble body to the left
98
+ symbolOffset = [xOff - totalWidth * 0.42, symbolOffset[1]];
99
+ // Shift text left to center within body
100
+ labelTextOffset = [-totalWidth * pointerRatio * 0.5, 0];
101
+ }
67
102
  } else {
68
- symbolOffset = [symbolOffset[0], typeof symbolOffset[1] === 'string'
69
- ? symbolOffset[1]
70
- : (symbolOffset[1] as number) + bubbleHeight * 0.35];
103
+ // Vertical pointer (up/down)
104
+ const totalHeight = bubbleHeight / (1 - pointerRatio);
105
+ finalSize = [bubbleWidth, totalHeight];
106
+
107
+ // Offset bubble so the pointer tip sits at the anchor price.
108
+ if (shape === 'labeldown') {
109
+ symbolOffset = [symbolOffset[0], typeof symbolOffset[1] === 'string'
110
+ ? symbolOffset[1]
111
+ : (symbolOffset[1] as number) - totalHeight * 0.42];
112
+ labelTextOffset = [0, -totalHeight * pointerRatio * 0.5];
113
+ } else {
114
+ symbolOffset = [symbolOffset[0], typeof symbolOffset[1] === 'string'
115
+ ? symbolOffset[1]
116
+ : (symbolOffset[1] as number) + totalHeight * 0.42];
117
+ labelTextOffset = [0, totalHeight * pointerRatio * 0.5];
118
+ }
71
119
  }
72
120
  } else if (shape === 'none') {
73
121
  finalSize = 0;
@@ -85,7 +133,7 @@ export class LabelRenderer implements SeriesRenderer {
85
133
  labelPosition.startsWith('inside');
86
134
 
87
135
  const item: any = {
88
- value: [i, yValue],
136
+ value: [xPos, yValue],
89
137
  symbol: symbol,
90
138
  symbolSize: finalSize,
91
139
  symbolOffset: symbolOffset,
@@ -96,6 +144,7 @@ export class LabelRenderer implements SeriesRenderer {
96
144
  show: !!text,
97
145
  position: labelPosition,
98
146
  distance: isInsideLabel ? 0 : 5,
147
+ offset: labelTextOffset,
99
148
  formatter: text,
100
149
  color: textcolor,
101
150
  fontSize: fontSize,
@@ -137,9 +186,9 @@ export class LabelRenderer implements SeriesRenderer {
137
186
  case 'label_up':
138
187
  return 'labelup';
139
188
  case 'label_left':
140
- return 'labeldown'; // Use labeldown shape, position text left
189
+ return 'labelleft';
141
190
  case 'label_right':
142
- return 'labeldown'; // Use labeldown shape, position text right
191
+ return 'labelright';
143
192
  case 'label_lower_left':
144
193
  return 'labeldown';
145
194
  case 'label_lower_right':
@@ -183,22 +232,16 @@ export class LabelRenderer implements SeriesRenderer {
183
232
  const s = style.startsWith('style_') ? style.substring(6) : style;
184
233
 
185
234
  switch (s) {
235
+ // All label_* styles render text INSIDE the bubble (TradingView behavior).
236
+ // The left/right/up/down refers to the pointer direction, not text position.
186
237
  case 'label_down':
187
- return 'inside';
188
238
  case 'label_up':
189
- return 'inside';
190
239
  case 'label_left':
191
- return 'left';
192
240
  case 'label_right':
193
- return 'right';
194
241
  case 'label_lower_left':
195
- return 'insideBottomLeft';
196
242
  case 'label_lower_right':
197
- return 'insideBottomRight';
198
243
  case 'label_upper_left':
199
- return 'insideTopLeft';
200
244
  case 'label_upper_right':
201
- return 'insideTopRight';
202
245
  case 'label_center':
203
246
  return 'inside';
204
247
  case 'text_outline':