@qfo/qfchart 0.6.7 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +4 -1
- package/dist/qfchart.min.browser.js +14 -14
- package/dist/qfchart.min.es.js +14 -14
- package/package.json +81 -81
- package/src/QFChart.ts +52 -0
- package/src/components/LayoutManager.ts +682 -679
- package/src/components/SeriesBuilder.ts +260 -250
- package/src/components/SeriesRendererFactory.ts +8 -0
- package/src/components/TableOverlayRenderer.ts +322 -0
- package/src/components/renderers/BoxRenderer.ts +258 -0
- package/src/components/renderers/DrawingLineRenderer.ts +194 -0
- package/src/components/renderers/FillRenderer.ts +99 -99
- package/src/components/renderers/LabelRenderer.ts +85 -41
- package/src/components/renderers/LinefillRenderer.ts +155 -0
- package/src/components/renderers/PolylineRenderer.ts +197 -0
- package/src/components/renderers/SeriesRenderer.ts +21 -20
- package/src/components/renderers/ShapeRenderer.ts +121 -121
- package/src/types.ts +2 -1
- package/src/utils/ShapeUtils.ts +156 -140
|
@@ -0,0 +1,194 @@
|
|
|
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
|
+
|
|
22
|
+
for (let i = 0; i < dataArray.length; i++) {
|
|
23
|
+
const val = dataArray[i];
|
|
24
|
+
if (!val) continue;
|
|
25
|
+
|
|
26
|
+
const items = Array.isArray(val) ? val : [val];
|
|
27
|
+
for (const ln of items) {
|
|
28
|
+
if (ln && typeof ln === 'object' && !ln._deleted) {
|
|
29
|
+
lineObjects.push(ln);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (lineObjects.length === 0) {
|
|
35
|
+
return { name: seriesName, type: 'custom', xAxisIndex, yAxisIndex, data: [], silent: true };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Compute y-range for axis scaling
|
|
39
|
+
let yMin = Infinity, yMax = -Infinity;
|
|
40
|
+
for (const ln of lineObjects) {
|
|
41
|
+
if (ln.y1 < yMin) yMin = ln.y1;
|
|
42
|
+
if (ln.y1 > yMax) yMax = ln.y1;
|
|
43
|
+
if (ln.y2 < yMin) yMin = ln.y2;
|
|
44
|
+
if (ln.y2 > yMax) yMax = ln.y2;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Use a SINGLE data entry spanning the full x-range so renderItem is always called.
|
|
48
|
+
// ECharts filters a data item only when ALL its x-dimensions are on the same side
|
|
49
|
+
// of the visible window. With dims 0=0 and 1=lastBar the item always straddles
|
|
50
|
+
// the viewport, so renderItem fires exactly once regardless of scroll position.
|
|
51
|
+
// Dims 2/3 are yMin/yMax for axis scaling.
|
|
52
|
+
const totalBars = (context.candlestickData?.length || 0) + offset;
|
|
53
|
+
const lastBarIndex = Math.max(0, totalBars - 1);
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
name: seriesName,
|
|
57
|
+
type: 'custom',
|
|
58
|
+
xAxisIndex,
|
|
59
|
+
yAxisIndex,
|
|
60
|
+
renderItem: (params: any, api: any) => {
|
|
61
|
+
const children: any[] = [];
|
|
62
|
+
|
|
63
|
+
for (const ln of lineObjects) {
|
|
64
|
+
if (ln._deleted) continue;
|
|
65
|
+
const xOff = (ln.xloc === 'bar_index' || ln.xloc === 'bi') ? offset : 0;
|
|
66
|
+
|
|
67
|
+
let p1 = api.coord([ln.x1 + xOff, ln.y1]);
|
|
68
|
+
let p2 = api.coord([ln.x2 + xOff, ln.y2]);
|
|
69
|
+
|
|
70
|
+
// Handle extend (none | left | right | both)
|
|
71
|
+
const extend = ln.extend || 'none';
|
|
72
|
+
if (extend !== 'none') {
|
|
73
|
+
const cs = params.coordSys;
|
|
74
|
+
[p1, p2] = this.extendLine(p1, p2, extend, cs.x, cs.x + cs.width, cs.y, cs.y + cs.height);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const color = ln.color || defaultColor;
|
|
78
|
+
const lineWidth = ln.width || 1;
|
|
79
|
+
|
|
80
|
+
children.push({
|
|
81
|
+
type: 'line',
|
|
82
|
+
shape: { x1: p1[0], y1: p1[1], x2: p2[0], y2: p2[1] },
|
|
83
|
+
style: {
|
|
84
|
+
stroke: color,
|
|
85
|
+
lineWidth,
|
|
86
|
+
lineDash: this.getDashPattern(ln.style),
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const style = ln.style || 'style_solid';
|
|
91
|
+
if (style === 'style_arrow_left' || style === 'style_arrow_both') {
|
|
92
|
+
const arrow = this.arrowHead(p2, p1, lineWidth, color);
|
|
93
|
+
if (arrow) children.push(arrow);
|
|
94
|
+
}
|
|
95
|
+
if (style === 'style_arrow_right' || style === 'style_arrow_both') {
|
|
96
|
+
const arrow = this.arrowHead(p1, p2, lineWidth, color);
|
|
97
|
+
if (arrow) children.push(arrow);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { type: 'group', children };
|
|
102
|
+
},
|
|
103
|
+
data: [[0, lastBarIndex, yMin, yMax]],
|
|
104
|
+
clip: true,
|
|
105
|
+
encode: { x: [0, 1], y: [2, 3] },
|
|
106
|
+
z: 15,
|
|
107
|
+
silent: true,
|
|
108
|
+
emphasis: { disabled: true },
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private getDashPattern(style: string): number[] | undefined {
|
|
113
|
+
switch (style) {
|
|
114
|
+
case 'style_dotted':
|
|
115
|
+
return [2, 2];
|
|
116
|
+
case 'style_dashed':
|
|
117
|
+
return [6, 4];
|
|
118
|
+
default:
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private extendLine(
|
|
124
|
+
p1: number[],
|
|
125
|
+
p2: number[],
|
|
126
|
+
extend: string,
|
|
127
|
+
left: number,
|
|
128
|
+
right: number,
|
|
129
|
+
top: number,
|
|
130
|
+
bottom: number,
|
|
131
|
+
): [number[], number[]] {
|
|
132
|
+
const dx = p2[0] - p1[0];
|
|
133
|
+
const dy = p2[1] - p1[1];
|
|
134
|
+
|
|
135
|
+
if (dx === 0 && dy === 0) return [p1, p2];
|
|
136
|
+
|
|
137
|
+
const extendPoint = (origin: number[], dir: number[]): number[] => {
|
|
138
|
+
let tMax = Infinity;
|
|
139
|
+
if (dir[0] !== 0) {
|
|
140
|
+
const tx = dir[0] > 0 ? (right - origin[0]) / dir[0] : (left - origin[0]) / dir[0];
|
|
141
|
+
tMax = Math.min(tMax, tx);
|
|
142
|
+
}
|
|
143
|
+
if (dir[1] !== 0) {
|
|
144
|
+
const ty = dir[1] > 0 ? (bottom - origin[1]) / dir[1] : (top - origin[1]) / dir[1];
|
|
145
|
+
tMax = Math.min(tMax, ty);
|
|
146
|
+
}
|
|
147
|
+
if (!isFinite(tMax)) tMax = 0;
|
|
148
|
+
return [origin[0] + tMax * dir[0], origin[1] + tMax * dir[1]];
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
let newP1 = p1;
|
|
152
|
+
let newP2 = p2;
|
|
153
|
+
|
|
154
|
+
if (extend === 'right' || extend === 'both') {
|
|
155
|
+
newP2 = extendPoint(p1, [dx, dy]);
|
|
156
|
+
}
|
|
157
|
+
if (extend === 'left' || extend === 'both') {
|
|
158
|
+
newP1 = extendPoint(p2, [-dx, -dy]);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return [newP1, newP2];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private arrowHead(from: number[], to: number[], lineWidth: number, color: string): any {
|
|
165
|
+
const dx = to[0] - from[0];
|
|
166
|
+
const dy = to[1] - from[1];
|
|
167
|
+
const len = Math.sqrt(dx * dx + dy * dy);
|
|
168
|
+
if (len < 1) return null;
|
|
169
|
+
|
|
170
|
+
const size = Math.max(8, lineWidth * 4);
|
|
171
|
+
const nx = dx / len;
|
|
172
|
+
const ny = dy / len;
|
|
173
|
+
|
|
174
|
+
// Arrow tip at `to`, base offset back by `size`
|
|
175
|
+
const bx = to[0] - nx * size;
|
|
176
|
+
const by = to[1] - ny * size;
|
|
177
|
+
|
|
178
|
+
// Perpendicular offset for arrowhead width
|
|
179
|
+
const px = -ny * size * 0.4;
|
|
180
|
+
const py = nx * size * 0.4;
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
type: 'polygon',
|
|
184
|
+
shape: {
|
|
185
|
+
points: [
|
|
186
|
+
[to[0], to[1]],
|
|
187
|
+
[bx + px, by + py],
|
|
188
|
+
[bx - px, by - py],
|
|
189
|
+
],
|
|
190
|
+
},
|
|
191
|
+
style: { fill: color },
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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.xloc === 'bi') ? (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
|
-
if (yloc === 'abovebar') {
|
|
33
|
-
if (candlestickData && candlestickData[
|
|
34
|
-
yValue = candlestickData[
|
|
46
|
+
if (yloc === 'abovebar' || yloc === 'AboveBar' || yloc === 'ab') {
|
|
47
|
+
if (candlestickData && candlestickData[xPos]) {
|
|
48
|
+
yValue = candlestickData[xPos].high;
|
|
35
49
|
}
|
|
36
50
|
symbolOffset = [0, '-150%'];
|
|
37
|
-
} else if (yloc === 'belowbar') {
|
|
38
|
-
if (candlestickData && candlestickData[
|
|
39
|
-
yValue = candlestickData[
|
|
51
|
+
} else if (yloc === 'belowbar' || yloc === 'BelowBar' || yloc === 'bl') {
|
|
52
|
+
if (candlestickData && candlestickData[xPos]) {
|
|
53
|
+
yValue = candlestickData[xPos].low;
|
|
40
54
|
}
|
|
41
55
|
symbolOffset = [0, '150%'];
|
|
42
56
|
}
|
|
@@ -50,24 +64,59 @@ export class LabelRenderer implements SeriesRenderer {
|
|
|
50
64
|
|
|
51
65
|
// Dynamically size the bubble to fit text content
|
|
52
66
|
let finalSize: number | number[];
|
|
53
|
-
|
|
67
|
+
const isBubble = shape === 'labeldown' || shape === 'shape_label_down' ||
|
|
68
|
+
shape === 'labelup' || shape === 'shape_label_up' ||
|
|
69
|
+
shape === 'labelleft' || shape === 'labelright';
|
|
70
|
+
// Track label text offset for centering text within the body
|
|
71
|
+
// (excluding the pointer area)
|
|
72
|
+
let labelTextOffset: [number, number] = [0, 0];
|
|
73
|
+
|
|
74
|
+
if (isBubble) {
|
|
54
75
|
// Approximate text width: chars * fontSize * avgCharWidthRatio (bold)
|
|
55
76
|
const textWidth = text.length * fontSize * 0.65;
|
|
56
77
|
const minWidth = fontSize * 2.5;
|
|
57
78
|
const bubbleWidth = Math.max(minWidth, textWidth + fontSize * 1.6);
|
|
58
79
|
const bubbleHeight = fontSize * 2.8;
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if (shape === '
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
80
|
+
|
|
81
|
+
// SVG pointer takes 3/24 = 12.5% of the path dimension
|
|
82
|
+
const pointerRatio = 3 / 24;
|
|
83
|
+
|
|
84
|
+
if (shape === 'labelleft' || shape === 'labelright') {
|
|
85
|
+
// Add extra width for the pointer
|
|
86
|
+
const totalWidth = bubbleWidth / (1 - pointerRatio);
|
|
87
|
+
finalSize = [totalWidth, bubbleHeight];
|
|
88
|
+
|
|
89
|
+
// Offset so the pointer tip sits at the anchor x position.
|
|
90
|
+
const xOff = typeof symbolOffset[0] === 'string' ? 0
|
|
91
|
+
: (symbolOffset[0] as number);
|
|
92
|
+
if (shape === 'labelleft') {
|
|
93
|
+
// Pointer on left → shift bubble body to the right
|
|
94
|
+
symbolOffset = [xOff + totalWidth * 0.42, symbolOffset[1]];
|
|
95
|
+
// Shift text right to center within body (not pointer)
|
|
96
|
+
labelTextOffset = [totalWidth * pointerRatio * 0.5, 0];
|
|
97
|
+
} else {
|
|
98
|
+
// Pointer on right → shift bubble body to the left
|
|
99
|
+
symbolOffset = [xOff - totalWidth * 0.42, symbolOffset[1]];
|
|
100
|
+
// Shift text left to center within body
|
|
101
|
+
labelTextOffset = [-totalWidth * pointerRatio * 0.5, 0];
|
|
102
|
+
}
|
|
67
103
|
} else {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
104
|
+
// Vertical pointer (up/down)
|
|
105
|
+
const totalHeight = bubbleHeight / (1 - pointerRatio);
|
|
106
|
+
finalSize = [bubbleWidth, totalHeight];
|
|
107
|
+
|
|
108
|
+
// Offset bubble so the pointer tip sits at the anchor price.
|
|
109
|
+
if (shape === 'labeldown') {
|
|
110
|
+
symbolOffset = [symbolOffset[0], typeof symbolOffset[1] === 'string'
|
|
111
|
+
? symbolOffset[1]
|
|
112
|
+
: (symbolOffset[1] as number) - totalHeight * 0.42];
|
|
113
|
+
labelTextOffset = [0, -totalHeight * pointerRatio * 0.5];
|
|
114
|
+
} else {
|
|
115
|
+
symbolOffset = [symbolOffset[0], typeof symbolOffset[1] === 'string'
|
|
116
|
+
? symbolOffset[1]
|
|
117
|
+
: (symbolOffset[1] as number) + totalHeight * 0.42];
|
|
118
|
+
labelTextOffset = [0, totalHeight * pointerRatio * 0.5];
|
|
119
|
+
}
|
|
71
120
|
}
|
|
72
121
|
} else if (shape === 'none') {
|
|
73
122
|
finalSize = 0;
|
|
@@ -85,7 +134,7 @@ export class LabelRenderer implements SeriesRenderer {
|
|
|
85
134
|
labelPosition.startsWith('inside');
|
|
86
135
|
|
|
87
136
|
const item: any = {
|
|
88
|
-
value: [
|
|
137
|
+
value: [xPos, yValue],
|
|
89
138
|
symbol: symbol,
|
|
90
139
|
symbolSize: finalSize,
|
|
91
140
|
symbolOffset: symbolOffset,
|
|
@@ -96,13 +145,14 @@ export class LabelRenderer implements SeriesRenderer {
|
|
|
96
145
|
show: !!text,
|
|
97
146
|
position: labelPosition,
|
|
98
147
|
distance: isInsideLabel ? 0 : 5,
|
|
148
|
+
offset: labelTextOffset,
|
|
99
149
|
formatter: text,
|
|
100
150
|
color: textcolor,
|
|
101
151
|
fontSize: fontSize,
|
|
102
152
|
fontWeight: 'bold',
|
|
103
153
|
align: isInsideLabel ? 'center'
|
|
104
|
-
: textalign === 'align_left' ? 'left'
|
|
105
|
-
: textalign === 'align_right' ? 'right'
|
|
154
|
+
: (textalign === 'align_left' || textalign === 'left') ? 'left'
|
|
155
|
+
: (textalign === 'align_right' || textalign === 'right') ? 'right'
|
|
106
156
|
: 'center',
|
|
107
157
|
verticalAlign: 'middle',
|
|
108
158
|
padding: [2, 6],
|
|
@@ -137,9 +187,9 @@ export class LabelRenderer implements SeriesRenderer {
|
|
|
137
187
|
case 'label_up':
|
|
138
188
|
return 'labelup';
|
|
139
189
|
case 'label_left':
|
|
140
|
-
return '
|
|
190
|
+
return 'labelleft';
|
|
141
191
|
case 'label_right':
|
|
142
|
-
return '
|
|
192
|
+
return 'labelright';
|
|
143
193
|
case 'label_lower_left':
|
|
144
194
|
return 'labeldown';
|
|
145
195
|
case 'label_lower_right':
|
|
@@ -183,31 +233,25 @@ export class LabelRenderer implements SeriesRenderer {
|
|
|
183
233
|
const s = style.startsWith('style_') ? style.substring(6) : style;
|
|
184
234
|
|
|
185
235
|
switch (s) {
|
|
236
|
+
// All label_* styles render text INSIDE the bubble (TradingView behavior).
|
|
237
|
+
// The left/right/up/down refers to the pointer direction, not text position.
|
|
186
238
|
case 'label_down':
|
|
187
|
-
return 'inside';
|
|
188
239
|
case 'label_up':
|
|
189
|
-
return 'inside';
|
|
190
240
|
case 'label_left':
|
|
191
|
-
return 'left';
|
|
192
241
|
case 'label_right':
|
|
193
|
-
return 'right';
|
|
194
242
|
case 'label_lower_left':
|
|
195
|
-
return 'insideBottomLeft';
|
|
196
243
|
case 'label_lower_right':
|
|
197
|
-
return 'insideBottomRight';
|
|
198
244
|
case 'label_upper_left':
|
|
199
|
-
return 'insideTopLeft';
|
|
200
245
|
case 'label_upper_right':
|
|
201
|
-
return 'insideTopRight';
|
|
202
246
|
case 'label_center':
|
|
203
247
|
return 'inside';
|
|
204
248
|
case 'text_outline':
|
|
205
249
|
case 'none':
|
|
206
250
|
// Text only, positioned based on yloc
|
|
207
|
-
return yloc === 'abovebar' ? 'top' : yloc === 'belowbar' ? 'bottom' : 'top';
|
|
251
|
+
return (yloc === 'abovebar' || yloc === 'AboveBar' || yloc === 'ab') ? 'top' : (yloc === 'belowbar' || yloc === 'BelowBar' || yloc === 'bl') ? 'bottom' : 'top';
|
|
208
252
|
default:
|
|
209
253
|
// For simple shapes (circle, diamond, etc.), text goes outside
|
|
210
|
-
return yloc === 'belowbar' ? 'bottom' : 'top';
|
|
254
|
+
return (yloc === 'belowbar' || yloc === 'BelowBar' || yloc === 'bl') ? 'bottom' : 'top';
|
|
211
255
|
}
|
|
212
256
|
}
|
|
213
257
|
|