@qfo/qfchart 0.6.6 → 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.
- package/dist/index.d.ts +1 -1
- package/dist/qfchart.min.browser.js +16 -16
- package/dist/qfchart.min.es.js +16 -16
- package/package.json +81 -81
- package/src/QFChart.ts +1434 -1434
- package/src/components/SeriesBuilder.ts +251 -250
- package/src/components/SeriesRendererFactory.ts +42 -36
- package/src/components/renderers/DrawingLineRenderer.ts +188 -0
- package/src/components/renderers/FillRenderer.ts +99 -99
- package/src/components/renderers/LabelRenderer.ts +274 -0
- package/src/components/renderers/LinefillRenderer.ts +167 -0
- package/src/components/renderers/SeriesRenderer.ts +21 -20
- package/src/types.ts +207 -205
- package/src/utils/ShapeUtils.ts +148 -140
|
@@ -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:
|
|
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
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
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, dataIndexOffset } = context;
|
|
7
|
+
const offset = dataIndexOffset || 0;
|
|
8
|
+
|
|
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
|
+
}
|
|
24
|
+
|
|
25
|
+
const labelData = labelObjects
|
|
26
|
+
.map((lbl) => {
|
|
27
|
+
const text = lbl.text || '';
|
|
28
|
+
const color = lbl.color || '#2962ff';
|
|
29
|
+
const textcolor = lbl.textcolor || '#ffffff';
|
|
30
|
+
const yloc = lbl.yloc || 'price';
|
|
31
|
+
const styleRaw = lbl.style || 'style_label_down';
|
|
32
|
+
const size = lbl.size || 'normal';
|
|
33
|
+
const textalign = lbl.textalign || 'align_center';
|
|
34
|
+
const tooltip = lbl.tooltip || '';
|
|
35
|
+
|
|
36
|
+
// Map Pine style string to shape name for ShapeUtils
|
|
37
|
+
const shape = this.styleToShape(styleRaw);
|
|
38
|
+
|
|
39
|
+
// Determine X position using label's own x coordinate
|
|
40
|
+
const xPos = lbl.xloc === 'bar_index' ? (lbl.x + offset) : lbl.x;
|
|
41
|
+
|
|
42
|
+
// Determine Y value based on yloc
|
|
43
|
+
let yValue = lbl.y;
|
|
44
|
+
let symbolOffset: (string | number)[] = [0, 0];
|
|
45
|
+
|
|
46
|
+
if (yloc === 'abovebar') {
|
|
47
|
+
if (candlestickData && candlestickData[xPos]) {
|
|
48
|
+
yValue = candlestickData[xPos].high;
|
|
49
|
+
}
|
|
50
|
+
symbolOffset = [0, '-150%'];
|
|
51
|
+
} else if (yloc === 'belowbar') {
|
|
52
|
+
if (candlestickData && candlestickData[xPos]) {
|
|
53
|
+
yValue = candlestickData[xPos].low;
|
|
54
|
+
}
|
|
55
|
+
symbolOffset = [0, '150%'];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Get symbol from ShapeUtils
|
|
59
|
+
const symbol = ShapeUtils.getShapeSymbol(shape);
|
|
60
|
+
const symbolSize = ShapeUtils.getShapeSize(size);
|
|
61
|
+
|
|
62
|
+
// Compute font size for this label
|
|
63
|
+
const fontSize = this.getSizePx(size);
|
|
64
|
+
|
|
65
|
+
// Dynamically size the bubble to fit text content
|
|
66
|
+
let finalSize: number | number[];
|
|
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) {
|
|
74
|
+
// Approximate text width: chars * fontSize * avgCharWidthRatio (bold)
|
|
75
|
+
const textWidth = text.length * fontSize * 0.65;
|
|
76
|
+
const minWidth = fontSize * 2.5;
|
|
77
|
+
const bubbleWidth = Math.max(minWidth, textWidth + fontSize * 1.6);
|
|
78
|
+
const bubbleHeight = fontSize * 2.8;
|
|
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
|
+
}
|
|
102
|
+
} else {
|
|
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
|
+
}
|
|
119
|
+
}
|
|
120
|
+
} else if (shape === 'none') {
|
|
121
|
+
finalSize = 0;
|
|
122
|
+
} else {
|
|
123
|
+
if (Array.isArray(symbolSize)) {
|
|
124
|
+
finalSize = [symbolSize[0] * 1.5, symbolSize[1] * 1.5];
|
|
125
|
+
} else {
|
|
126
|
+
finalSize = symbolSize * 1.5;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Determine label position based on style direction
|
|
131
|
+
const labelPosition = this.getLabelPosition(styleRaw, yloc);
|
|
132
|
+
const isInsideLabel = labelPosition === 'inside' ||
|
|
133
|
+
labelPosition.startsWith('inside');
|
|
134
|
+
|
|
135
|
+
const item: any = {
|
|
136
|
+
value: [xPos, yValue],
|
|
137
|
+
symbol: symbol,
|
|
138
|
+
symbolSize: finalSize,
|
|
139
|
+
symbolOffset: symbolOffset,
|
|
140
|
+
itemStyle: {
|
|
141
|
+
color: color,
|
|
142
|
+
},
|
|
143
|
+
label: {
|
|
144
|
+
show: !!text,
|
|
145
|
+
position: labelPosition,
|
|
146
|
+
distance: isInsideLabel ? 0 : 5,
|
|
147
|
+
offset: labelTextOffset,
|
|
148
|
+
formatter: text,
|
|
149
|
+
color: textcolor,
|
|
150
|
+
fontSize: fontSize,
|
|
151
|
+
fontWeight: 'bold',
|
|
152
|
+
align: isInsideLabel ? 'center'
|
|
153
|
+
: textalign === 'align_left' ? 'left'
|
|
154
|
+
: textalign === 'align_right' ? 'right'
|
|
155
|
+
: 'center',
|
|
156
|
+
verticalAlign: 'middle',
|
|
157
|
+
padding: [2, 6],
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
if (tooltip) {
|
|
162
|
+
item.tooltip = { formatter: tooltip };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return item;
|
|
166
|
+
})
|
|
167
|
+
.filter((item) => item !== null);
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
name: seriesName,
|
|
171
|
+
type: 'scatter',
|
|
172
|
+
xAxisIndex: xAxisIndex,
|
|
173
|
+
yAxisIndex: yAxisIndex,
|
|
174
|
+
data: labelData,
|
|
175
|
+
z: 20,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private styleToShape(style: string): string {
|
|
180
|
+
// Strip 'style_' prefix
|
|
181
|
+
const s = style.startsWith('style_') ? style.substring(6) : style;
|
|
182
|
+
|
|
183
|
+
switch (s) {
|
|
184
|
+
case 'label_down':
|
|
185
|
+
return 'labeldown';
|
|
186
|
+
case 'label_up':
|
|
187
|
+
return 'labelup';
|
|
188
|
+
case 'label_left':
|
|
189
|
+
return 'labelleft';
|
|
190
|
+
case 'label_right':
|
|
191
|
+
return 'labelright';
|
|
192
|
+
case 'label_lower_left':
|
|
193
|
+
return 'labeldown';
|
|
194
|
+
case 'label_lower_right':
|
|
195
|
+
return 'labeldown';
|
|
196
|
+
case 'label_upper_left':
|
|
197
|
+
return 'labelup';
|
|
198
|
+
case 'label_upper_right':
|
|
199
|
+
return 'labelup';
|
|
200
|
+
case 'label_center':
|
|
201
|
+
return 'labeldown';
|
|
202
|
+
case 'circle':
|
|
203
|
+
return 'circle';
|
|
204
|
+
case 'square':
|
|
205
|
+
return 'square';
|
|
206
|
+
case 'diamond':
|
|
207
|
+
return 'diamond';
|
|
208
|
+
case 'flag':
|
|
209
|
+
return 'flag';
|
|
210
|
+
case 'arrowup':
|
|
211
|
+
return 'arrowup';
|
|
212
|
+
case 'arrowdown':
|
|
213
|
+
return 'arrowdown';
|
|
214
|
+
case 'cross':
|
|
215
|
+
return 'cross';
|
|
216
|
+
case 'xcross':
|
|
217
|
+
return 'xcross';
|
|
218
|
+
case 'triangleup':
|
|
219
|
+
return 'triangleup';
|
|
220
|
+
case 'triangledown':
|
|
221
|
+
return 'triangledown';
|
|
222
|
+
case 'text_outline':
|
|
223
|
+
return 'none';
|
|
224
|
+
case 'none':
|
|
225
|
+
return 'none';
|
|
226
|
+
default:
|
|
227
|
+
return 'labeldown';
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private getLabelPosition(style: string, yloc: string): string {
|
|
232
|
+
const s = style.startsWith('style_') ? style.substring(6) : style;
|
|
233
|
+
|
|
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.
|
|
237
|
+
case 'label_down':
|
|
238
|
+
case 'label_up':
|
|
239
|
+
case 'label_left':
|
|
240
|
+
case 'label_right':
|
|
241
|
+
case 'label_lower_left':
|
|
242
|
+
case 'label_lower_right':
|
|
243
|
+
case 'label_upper_left':
|
|
244
|
+
case 'label_upper_right':
|
|
245
|
+
case 'label_center':
|
|
246
|
+
return 'inside';
|
|
247
|
+
case 'text_outline':
|
|
248
|
+
case 'none':
|
|
249
|
+
// Text only, positioned based on yloc
|
|
250
|
+
return yloc === 'abovebar' ? 'top' : yloc === 'belowbar' ? 'bottom' : 'top';
|
|
251
|
+
default:
|
|
252
|
+
// For simple shapes (circle, diamond, etc.), text goes outside
|
|
253
|
+
return yloc === 'belowbar' ? 'bottom' : 'top';
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private getSizePx(size: string): number {
|
|
258
|
+
switch (size) {
|
|
259
|
+
case 'tiny':
|
|
260
|
+
return 8;
|
|
261
|
+
case 'small':
|
|
262
|
+
return 9;
|
|
263
|
+
case 'normal':
|
|
264
|
+
case 'auto':
|
|
265
|
+
return 10;
|
|
266
|
+
case 'large':
|
|
267
|
+
return 12;
|
|
268
|
+
case 'huge':
|
|
269
|
+
return 14;
|
|
270
|
+
default:
|
|
271
|
+
return 10;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|