@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,155 @@
|
|
|
1
|
+
import { SeriesRenderer, RenderContext } from './SeriesRenderer';
|
|
2
|
+
import { ColorUtils } from '../../utils/ColorUtils';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Renderer for Pine Script linefill.* drawing objects.
|
|
6
|
+
* Each linefill fills the area between two line objects as a polygon.
|
|
7
|
+
*
|
|
8
|
+
* Style name: 'linefill'
|
|
9
|
+
*/
|
|
10
|
+
export class LinefillRenderer implements SeriesRenderer {
|
|
11
|
+
render(context: RenderContext): any {
|
|
12
|
+
const { seriesName, xAxisIndex, yAxisIndex, dataArray, dataIndexOffset } = context;
|
|
13
|
+
const offset = dataIndexOffset || 0;
|
|
14
|
+
|
|
15
|
+
// Collect all non-deleted linefill objects from the sparse dataArray.
|
|
16
|
+
// Same aggregation pattern as DrawingLineRenderer — objects are stored
|
|
17
|
+
// as an array in a single data entry.
|
|
18
|
+
const fillObjects: any[] = [];
|
|
19
|
+
|
|
20
|
+
for (let i = 0; i < dataArray.length; i++) {
|
|
21
|
+
const val = dataArray[i];
|
|
22
|
+
if (!val) continue;
|
|
23
|
+
|
|
24
|
+
const items = Array.isArray(val) ? val : [val];
|
|
25
|
+
for (const lf of items) {
|
|
26
|
+
if (!lf || typeof lf !== 'object' || lf._deleted) continue;
|
|
27
|
+
|
|
28
|
+
const line1 = lf.line1;
|
|
29
|
+
const line2 = lf.line2;
|
|
30
|
+
if (!line1 || !line2 || line1._deleted || line2._deleted) continue;
|
|
31
|
+
|
|
32
|
+
fillObjects.push(lf);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (fillObjects.length === 0) {
|
|
37
|
+
return { name: seriesName, type: 'custom', xAxisIndex, yAxisIndex, data: [], silent: true };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Compute y-range for axis scaling
|
|
41
|
+
let yMin = Infinity, yMax = -Infinity;
|
|
42
|
+
for (const lf of fillObjects) {
|
|
43
|
+
for (const y of [lf.line1.y1, lf.line1.y2, lf.line2.y1, lf.line2.y2]) {
|
|
44
|
+
if (y < yMin) yMin = y;
|
|
45
|
+
if (y > yMax) yMax = y;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Use a SINGLE data entry spanning the full x-range so renderItem is always called.
|
|
50
|
+
// ECharts filters a data item only when ALL its x-dimensions are on the same side
|
|
51
|
+
// of the visible window. With dims 0=0 and 1=lastBar the item always straddles
|
|
52
|
+
// the viewport, so renderItem fires exactly once regardless of scroll position.
|
|
53
|
+
// Dims 2/3 are yMin/yMax for axis scaling.
|
|
54
|
+
const totalBars = (context.candlestickData?.length || 0) + offset;
|
|
55
|
+
const lastBarIndex = Math.max(0, totalBars - 1);
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
name: seriesName,
|
|
59
|
+
type: 'custom',
|
|
60
|
+
xAxisIndex,
|
|
61
|
+
yAxisIndex,
|
|
62
|
+
renderItem: (params: any, api: any) => {
|
|
63
|
+
const children: any[] = [];
|
|
64
|
+
|
|
65
|
+
for (const lf of fillObjects) {
|
|
66
|
+
if (lf._deleted) continue;
|
|
67
|
+
const line1 = lf.line1;
|
|
68
|
+
const line2 = lf.line2;
|
|
69
|
+
if (!line1 || !line2 || line1._deleted || line2._deleted) continue;
|
|
70
|
+
|
|
71
|
+
const xOff1 = (line1.xloc === 'bar_index' || line1.xloc === 'bi') ? offset : 0;
|
|
72
|
+
const xOff2 = (line2.xloc === 'bar_index' || line2.xloc === 'bi') ? offset : 0;
|
|
73
|
+
|
|
74
|
+
let p1Start = api.coord([line1.x1 + xOff1, line1.y1]);
|
|
75
|
+
let p1End = api.coord([line1.x2 + xOff1, line1.y2]);
|
|
76
|
+
let p2Start = api.coord([line2.x1 + xOff2, line2.y1]);
|
|
77
|
+
let p2End = api.coord([line2.x2 + xOff2, line2.y2]);
|
|
78
|
+
|
|
79
|
+
// Handle line extensions
|
|
80
|
+
const extend1 = line1.extend || 'none';
|
|
81
|
+
const extend2 = line2.extend || 'none';
|
|
82
|
+
if (extend1 !== 'none' || extend2 !== 'none') {
|
|
83
|
+
const cs = params.coordSys;
|
|
84
|
+
const csLeft = cs.x, csRight = cs.x + cs.width;
|
|
85
|
+
const csTop = cs.y, csBottom = cs.y + cs.height;
|
|
86
|
+
if (extend1 !== 'none') {
|
|
87
|
+
[p1Start, p1End] = this.extendLine(p1Start, p1End, extend1, csLeft, csRight, csTop, csBottom);
|
|
88
|
+
}
|
|
89
|
+
if (extend2 !== 'none') {
|
|
90
|
+
[p2Start, p2End] = this.extendLine(p2Start, p2End, extend2, csLeft, csRight, csTop, csBottom);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const { color: fillColor, opacity: fillOpacity } = ColorUtils.parseColor(lf.color || 'rgba(128, 128, 128, 0.2)');
|
|
95
|
+
|
|
96
|
+
children.push({
|
|
97
|
+
type: 'polygon',
|
|
98
|
+
shape: { points: [p1Start, p1End, p2End, p2Start] },
|
|
99
|
+
style: { fill: fillColor, opacity: fillOpacity },
|
|
100
|
+
silent: true,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { type: 'group', children };
|
|
105
|
+
},
|
|
106
|
+
data: [[0, lastBarIndex, yMin, yMax]],
|
|
107
|
+
clip: true,
|
|
108
|
+
encode: { x: [0, 1], y: [2, 3] },
|
|
109
|
+
z: 10, // Behind lines (z=15) but above other elements
|
|
110
|
+
silent: true,
|
|
111
|
+
emphasis: { disabled: true },
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private extendLine(
|
|
116
|
+
p1: number[],
|
|
117
|
+
p2: number[],
|
|
118
|
+
extend: string,
|
|
119
|
+
left: number,
|
|
120
|
+
right: number,
|
|
121
|
+
top: number,
|
|
122
|
+
bottom: number,
|
|
123
|
+
): [number[], number[]] {
|
|
124
|
+
const dx = p2[0] - p1[0];
|
|
125
|
+
const dy = p2[1] - p1[1];
|
|
126
|
+
|
|
127
|
+
if (dx === 0 && dy === 0) return [p1, p2];
|
|
128
|
+
|
|
129
|
+
const extendPoint = (origin: number[], dir: number[]): number[] => {
|
|
130
|
+
let tMax = Infinity;
|
|
131
|
+
if (dir[0] !== 0) {
|
|
132
|
+
const tx = dir[0] > 0 ? (right - origin[0]) / dir[0] : (left - origin[0]) / dir[0];
|
|
133
|
+
tMax = Math.min(tMax, tx);
|
|
134
|
+
}
|
|
135
|
+
if (dir[1] !== 0) {
|
|
136
|
+
const ty = dir[1] > 0 ? (bottom - origin[1]) / dir[1] : (top - origin[1]) / dir[1];
|
|
137
|
+
tMax = Math.min(tMax, ty);
|
|
138
|
+
}
|
|
139
|
+
if (!isFinite(tMax)) tMax = 0;
|
|
140
|
+
return [origin[0] + tMax * dir[0], origin[1] + tMax * dir[1]];
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
let newP1 = p1;
|
|
144
|
+
let newP2 = p2;
|
|
145
|
+
|
|
146
|
+
if (extend === 'right' || extend === 'both') {
|
|
147
|
+
newP2 = extendPoint(p1, [dx, dy]);
|
|
148
|
+
}
|
|
149
|
+
if (extend === 'left' || extend === 'both') {
|
|
150
|
+
newP1 = extendPoint(p2, [-dx, -dy]);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return [newP1, newP2];
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { SeriesRenderer, RenderContext } from './SeriesRenderer';
|
|
2
|
+
import { ColorUtils } from '../../utils/ColorUtils';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Renderer for Pine Script polyline.* drawing objects.
|
|
6
|
+
* Each polyline is defined by an array of chart.point objects, connected
|
|
7
|
+
* sequentially with straight or curved segments, optionally closed and filled.
|
|
8
|
+
*
|
|
9
|
+
* Style name: 'drawing_polyline'
|
|
10
|
+
*/
|
|
11
|
+
export class PolylineRenderer implements SeriesRenderer {
|
|
12
|
+
render(context: RenderContext): any {
|
|
13
|
+
const { seriesName, xAxisIndex, yAxisIndex, dataArray, dataIndexOffset } = context;
|
|
14
|
+
const offset = dataIndexOffset || 0;
|
|
15
|
+
|
|
16
|
+
// Collect all non-deleted polyline objects from the sparse dataArray.
|
|
17
|
+
// Same aggregation pattern as DrawingLineRenderer — objects are stored
|
|
18
|
+
// as an array in a single data entry.
|
|
19
|
+
const polyObjects: any[] = [];
|
|
20
|
+
|
|
21
|
+
for (let i = 0; i < dataArray.length; i++) {
|
|
22
|
+
const val = dataArray[i];
|
|
23
|
+
if (!val) continue;
|
|
24
|
+
|
|
25
|
+
const items = Array.isArray(val) ? val : [val];
|
|
26
|
+
for (const pl of items) {
|
|
27
|
+
if (pl && typeof pl === 'object' && !pl._deleted && pl.points && pl.points.length >= 2) {
|
|
28
|
+
polyObjects.push(pl);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (polyObjects.length === 0) {
|
|
34
|
+
return { name: seriesName, type: 'custom', xAxisIndex, yAxisIndex, data: [], silent: true };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Compute y-range across all polylines for axis scaling
|
|
38
|
+
let yMin = Infinity, yMax = -Infinity;
|
|
39
|
+
for (const pl of polyObjects) {
|
|
40
|
+
for (const pt of pl.points) {
|
|
41
|
+
const p = pt.price ?? 0;
|
|
42
|
+
if (p < yMin) yMin = p;
|
|
43
|
+
if (p > yMax) yMax = p;
|
|
44
|
+
}
|
|
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 pl of polyObjects) {
|
|
64
|
+
if (pl._deleted) continue;
|
|
65
|
+
const points = pl.points;
|
|
66
|
+
if (!points || points.length < 2) continue;
|
|
67
|
+
|
|
68
|
+
const useBi = pl.xloc === 'bi' || pl.xloc === 'bar_index';
|
|
69
|
+
const xOff = useBi ? offset : 0;
|
|
70
|
+
|
|
71
|
+
// Convert chart.point objects to pixel coordinates
|
|
72
|
+
const pixelPoints: number[][] = [];
|
|
73
|
+
for (const pt of points) {
|
|
74
|
+
const x = useBi ? (pt.index ?? 0) + xOff : (pt.time ?? 0);
|
|
75
|
+
const y = pt.price ?? 0;
|
|
76
|
+
pixelPoints.push(api.coord([x, y]));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (pixelPoints.length < 2) continue;
|
|
80
|
+
|
|
81
|
+
const lineColor = pl.line_color || '#2962ff';
|
|
82
|
+
const lineWidth = pl.line_width || 1;
|
|
83
|
+
const dashPattern = this.getDashPattern(pl.line_style);
|
|
84
|
+
|
|
85
|
+
// Fill shape (rendered behind stroke)
|
|
86
|
+
if (pl.fill_color && pl.fill_color !== '' && pl.fill_color !== 'na') {
|
|
87
|
+
const { color: fillColor, opacity: fillOpacity } = ColorUtils.parseColor(pl.fill_color);
|
|
88
|
+
|
|
89
|
+
if (pl.curved) {
|
|
90
|
+
const pathData = this.buildCurvedPath(pixelPoints, pl.closed);
|
|
91
|
+
children.push({
|
|
92
|
+
type: 'path',
|
|
93
|
+
shape: { pathData: pathData + ' Z' },
|
|
94
|
+
style: { fill: fillColor, opacity: fillOpacity, stroke: 'none' },
|
|
95
|
+
silent: true,
|
|
96
|
+
});
|
|
97
|
+
} else {
|
|
98
|
+
children.push({
|
|
99
|
+
type: 'polygon',
|
|
100
|
+
shape: { points: pixelPoints },
|
|
101
|
+
style: { fill: fillColor, opacity: fillOpacity, stroke: 'none' },
|
|
102
|
+
silent: true,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Stroke (line segments)
|
|
108
|
+
if (pl.curved) {
|
|
109
|
+
const pathData = this.buildCurvedPath(pixelPoints, pl.closed);
|
|
110
|
+
children.push({
|
|
111
|
+
type: 'path',
|
|
112
|
+
shape: { pathData },
|
|
113
|
+
style: { fill: 'none', stroke: lineColor, lineWidth, lineDash: dashPattern },
|
|
114
|
+
silent: true,
|
|
115
|
+
});
|
|
116
|
+
} else {
|
|
117
|
+
const allPoints = pl.closed ? [...pixelPoints, pixelPoints[0]] : pixelPoints;
|
|
118
|
+
children.push({
|
|
119
|
+
type: 'polyline',
|
|
120
|
+
shape: { points: allPoints },
|
|
121
|
+
style: { fill: 'none', stroke: lineColor, lineWidth, lineDash: dashPattern },
|
|
122
|
+
silent: true,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { type: 'group', children };
|
|
128
|
+
},
|
|
129
|
+
data: [[0, lastBarIndex, yMin, yMax]],
|
|
130
|
+
clip: true,
|
|
131
|
+
encode: { x: [0, 1], y: [2, 3] },
|
|
132
|
+
z: 12,
|
|
133
|
+
silent: true,
|
|
134
|
+
emphasis: { disabled: true },
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Build an SVG path string for a smooth curve through all points
|
|
140
|
+
* using Catmull-Rom → cubic bezier conversion.
|
|
141
|
+
*/
|
|
142
|
+
private buildCurvedPath(points: number[][], closed: boolean): string {
|
|
143
|
+
const n = points.length;
|
|
144
|
+
if (n < 2) return '';
|
|
145
|
+
if (n === 2) {
|
|
146
|
+
return `M ${points[0][0]} ${points[0][1]} L ${points[1][0]} ${points[1][1]}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Catmull-Rom tension (0.5 = centripetal)
|
|
150
|
+
const tension = 0.5;
|
|
151
|
+
let path = `M ${points[0][0]} ${points[0][1]}`;
|
|
152
|
+
|
|
153
|
+
// For closed curves, wrap around; for open, duplicate first/last
|
|
154
|
+
const getPoint = (i: number): number[] => {
|
|
155
|
+
if (closed) {
|
|
156
|
+
return points[((i % n) + n) % n];
|
|
157
|
+
}
|
|
158
|
+
if (i < 0) return points[0];
|
|
159
|
+
if (i >= n) return points[n - 1];
|
|
160
|
+
return points[i];
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const segmentCount = closed ? n : n - 1;
|
|
164
|
+
|
|
165
|
+
for (let i = 0; i < segmentCount; i++) {
|
|
166
|
+
const p0 = getPoint(i - 1);
|
|
167
|
+
const p1 = getPoint(i);
|
|
168
|
+
const p2 = getPoint(i + 1);
|
|
169
|
+
const p3 = getPoint(i + 2);
|
|
170
|
+
|
|
171
|
+
// Convert Catmull-Rom to cubic bezier control points
|
|
172
|
+
const cp1x = p1[0] + (p2[0] - p0[0]) * tension / 3;
|
|
173
|
+
const cp1y = p1[1] + (p2[1] - p0[1]) * tension / 3;
|
|
174
|
+
const cp2x = p2[0] - (p3[0] - p1[0]) * tension / 3;
|
|
175
|
+
const cp2y = p2[1] - (p3[1] - p1[1]) * tension / 3;
|
|
176
|
+
|
|
177
|
+
path += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p2[0]} ${p2[1]}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (closed) {
|
|
181
|
+
path += ' Z';
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return path;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private getDashPattern(style: string): number[] | undefined {
|
|
188
|
+
switch (style) {
|
|
189
|
+
case 'style_dotted':
|
|
190
|
+
return [2, 2];
|
|
191
|
+
case 'style_dashed':
|
|
192
|
+
return [6, 4];
|
|
193
|
+
default:
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -1,20 +1,21 @@
|
|
|
1
|
-
import { IndicatorPlot, OHLCV } from '../../types';
|
|
2
|
-
|
|
3
|
-
export interface RenderContext {
|
|
4
|
-
seriesName: string;
|
|
5
|
-
xAxisIndex: number;
|
|
6
|
-
yAxisIndex: number;
|
|
7
|
-
dataArray: any[];
|
|
8
|
-
colorArray: any[];
|
|
9
|
-
optionsArray: any[];
|
|
10
|
-
plotOptions: any;
|
|
11
|
-
candlestickData?: OHLCV[]; // For shape positioning
|
|
12
|
-
plotDataArrays?: Map<string, number[]>; // For fill plots
|
|
13
|
-
indicatorId?: string;
|
|
14
|
-
plotName?: string;
|
|
15
|
-
indicator?: any; // Reference to parent indicator object if needed
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
1
|
+
import { IndicatorPlot, OHLCV } from '../../types';
|
|
2
|
+
|
|
3
|
+
export interface RenderContext {
|
|
4
|
+
seriesName: string;
|
|
5
|
+
xAxisIndex: number;
|
|
6
|
+
yAxisIndex: number;
|
|
7
|
+
dataArray: any[];
|
|
8
|
+
colorArray: any[];
|
|
9
|
+
optionsArray: any[];
|
|
10
|
+
plotOptions: any;
|
|
11
|
+
candlestickData?: OHLCV[]; // For shape positioning
|
|
12
|
+
plotDataArrays?: Map<string, number[]>; // For fill plots
|
|
13
|
+
indicatorId?: string;
|
|
14
|
+
plotName?: string;
|
|
15
|
+
indicator?: any; // Reference to parent indicator object if needed
|
|
16
|
+
dataIndexOffset?: number; // Padding offset for converting bar_index to ECharts index
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SeriesRenderer {
|
|
20
|
+
render(context: RenderContext): any;
|
|
21
|
+
}
|
|
@@ -1,121 +1,121 @@
|
|
|
1
|
-
import { SeriesRenderer, RenderContext } from './SeriesRenderer';
|
|
2
|
-
import { ShapeUtils } from '../../utils/ShapeUtils';
|
|
3
|
-
|
|
4
|
-
export class ShapeRenderer implements SeriesRenderer {
|
|
5
|
-
render(context: RenderContext): any {
|
|
6
|
-
const { seriesName, xAxisIndex, yAxisIndex, dataArray, colorArray, optionsArray, plotOptions, candlestickData } = context;
|
|
7
|
-
const defaultColor = '#2962ff';
|
|
8
|
-
|
|
9
|
-
const shapeData = dataArray
|
|
10
|
-
.map((val, i) => {
|
|
11
|
-
// Merge global options with per-point options to get location first
|
|
12
|
-
const pointOpts = optionsArray[i] || {};
|
|
13
|
-
const globalOpts = plotOptions;
|
|
14
|
-
const location = pointOpts.location || globalOpts.location || 'absolute';
|
|
15
|
-
|
|
16
|
-
// For location="absolute", always draw the shape (ignore value)
|
|
17
|
-
// For other locations, only draw if value is truthy (TradingView behavior)
|
|
18
|
-
if (location !== 'absolute' && !val) {
|
|
19
|
-
return null;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
// If we get here and val is null/undefined, it means location is absolute
|
|
23
|
-
// In that case, we still need a valid value for positioning
|
|
24
|
-
// Use the value if it exists, otherwise we'd need a fallback
|
|
25
|
-
// But in TradingView, absolute location still expects a value for Y position
|
|
26
|
-
if (val === null || val === undefined) {
|
|
27
|
-
return null; // Can't plot without a Y coordinate
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const color = pointOpts.color || globalOpts.color || defaultColor;
|
|
31
|
-
const shape = pointOpts.shape || globalOpts.shape || 'circle';
|
|
32
|
-
const size = pointOpts.size || globalOpts.size || 'normal';
|
|
33
|
-
const text = pointOpts.text || globalOpts.text;
|
|
34
|
-
const textColor = pointOpts.textcolor || globalOpts.textcolor || 'white';
|
|
35
|
-
|
|
36
|
-
// NEW: Get width and height
|
|
37
|
-
const width = pointOpts.width || globalOpts.width;
|
|
38
|
-
const height = pointOpts.height || globalOpts.height;
|
|
39
|
-
|
|
40
|
-
// Positioning based on location
|
|
41
|
-
let yValue = val; // Default to absolute value
|
|
42
|
-
let symbolOffset: (string | number)[] = [0, 0];
|
|
43
|
-
|
|
44
|
-
if (location === 'abovebar') {
|
|
45
|
-
// Shape above the candle
|
|
46
|
-
if (candlestickData && candlestickData[i]) {
|
|
47
|
-
yValue = candlestickData[i].high;
|
|
48
|
-
}
|
|
49
|
-
symbolOffset = [0, '-150%']; // Shift up
|
|
50
|
-
} else if (location === 'belowbar') {
|
|
51
|
-
// Shape below the candle
|
|
52
|
-
if (candlestickData && candlestickData[i]) {
|
|
53
|
-
yValue = candlestickData[i].low;
|
|
54
|
-
}
|
|
55
|
-
symbolOffset = [0, '150%']; // Shift down
|
|
56
|
-
} else if (location === 'top') {
|
|
57
|
-
// Shape at top of chart - we need to use a very high value
|
|
58
|
-
// This would require knowing the y-axis max, which we don't have here easily
|
|
59
|
-
// For now, use a placeholder approach - might need to calculate from data
|
|
60
|
-
// Or we can use a percentage of the viewport? ECharts doesn't support that directly in scatter.
|
|
61
|
-
// Best approach: use a large multiplier of current value or track max
|
|
62
|
-
// Simplified: use coordinate system max (will need enhancement)
|
|
63
|
-
yValue = val; // For now, keep absolute - would need axis max
|
|
64
|
-
symbolOffset = [0, 0];
|
|
65
|
-
} else if (location === 'bottom') {
|
|
66
|
-
// Shape at bottom of chart
|
|
67
|
-
yValue = val; // For now, keep absolute - would need axis min
|
|
68
|
-
symbolOffset = [0, 0];
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const symbol = ShapeUtils.getShapeSymbol(shape);
|
|
72
|
-
const symbolSize = ShapeUtils.getShapeSize(size, width, height);
|
|
73
|
-
const rotate = ShapeUtils.getShapeRotation(shape);
|
|
74
|
-
|
|
75
|
-
// Special handling for labelup/down sizing - they contain text so they should be larger
|
|
76
|
-
let finalSize: number | number[] = symbolSize;
|
|
77
|
-
if (shape.includes('label')) {
|
|
78
|
-
// If custom size, scale it up for labels
|
|
79
|
-
if (Array.isArray(symbolSize)) {
|
|
80
|
-
finalSize = [symbolSize[0] * 2.5, symbolSize[1] * 2.5];
|
|
81
|
-
} else {
|
|
82
|
-
finalSize = symbolSize * 2.5;
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Get label configuration based on location
|
|
87
|
-
const labelConfig = ShapeUtils.getLabelConfig(shape, location);
|
|
88
|
-
|
|
89
|
-
const item: any = {
|
|
90
|
-
value: [i, yValue],
|
|
91
|
-
symbol: symbol,
|
|
92
|
-
symbolSize: finalSize,
|
|
93
|
-
symbolRotate: rotate,
|
|
94
|
-
symbolOffset: symbolOffset,
|
|
95
|
-
itemStyle: {
|
|
96
|
-
color: color,
|
|
97
|
-
},
|
|
98
|
-
label: {
|
|
99
|
-
show: !!text,
|
|
100
|
-
position: labelConfig.position,
|
|
101
|
-
distance: labelConfig.distance,
|
|
102
|
-
formatter: text,
|
|
103
|
-
color: textColor,
|
|
104
|
-
fontSize: 10,
|
|
105
|
-
fontWeight: 'bold',
|
|
106
|
-
},
|
|
107
|
-
};
|
|
108
|
-
|
|
109
|
-
return item;
|
|
110
|
-
})
|
|
111
|
-
.filter((item) => item !== null);
|
|
112
|
-
|
|
113
|
-
return {
|
|
114
|
-
name: seriesName,
|
|
115
|
-
type: 'scatter',
|
|
116
|
-
xAxisIndex: xAxisIndex,
|
|
117
|
-
yAxisIndex: yAxisIndex,
|
|
118
|
-
data: shapeData,
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
}
|
|
1
|
+
import { SeriesRenderer, RenderContext } from './SeriesRenderer';
|
|
2
|
+
import { ShapeUtils } from '../../utils/ShapeUtils';
|
|
3
|
+
|
|
4
|
+
export class ShapeRenderer implements SeriesRenderer {
|
|
5
|
+
render(context: RenderContext): any {
|
|
6
|
+
const { seriesName, xAxisIndex, yAxisIndex, dataArray, colorArray, optionsArray, plotOptions, candlestickData } = context;
|
|
7
|
+
const defaultColor = '#2962ff';
|
|
8
|
+
|
|
9
|
+
const shapeData = dataArray
|
|
10
|
+
.map((val, i) => {
|
|
11
|
+
// Merge global options with per-point options to get location first
|
|
12
|
+
const pointOpts = optionsArray[i] || {};
|
|
13
|
+
const globalOpts = plotOptions;
|
|
14
|
+
const location = pointOpts.location || globalOpts.location || 'absolute';
|
|
15
|
+
|
|
16
|
+
// For location="absolute", always draw the shape (ignore value)
|
|
17
|
+
// For other locations, only draw if value is truthy (TradingView behavior)
|
|
18
|
+
if (location !== 'absolute' && location !== 'Absolute' && !val) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// If we get here and val is null/undefined, it means location is absolute
|
|
23
|
+
// In that case, we still need a valid value for positioning
|
|
24
|
+
// Use the value if it exists, otherwise we'd need a fallback
|
|
25
|
+
// But in TradingView, absolute location still expects a value for Y position
|
|
26
|
+
if (val === null || val === undefined) {
|
|
27
|
+
return null; // Can't plot without a Y coordinate
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const color = pointOpts.color || globalOpts.color || defaultColor;
|
|
31
|
+
const shape = pointOpts.shape || globalOpts.shape || 'circle';
|
|
32
|
+
const size = pointOpts.size || globalOpts.size || 'normal';
|
|
33
|
+
const text = pointOpts.text || globalOpts.text;
|
|
34
|
+
const textColor = pointOpts.textcolor || globalOpts.textcolor || 'white';
|
|
35
|
+
|
|
36
|
+
// NEW: Get width and height
|
|
37
|
+
const width = pointOpts.width || globalOpts.width;
|
|
38
|
+
const height = pointOpts.height || globalOpts.height;
|
|
39
|
+
|
|
40
|
+
// Positioning based on location
|
|
41
|
+
let yValue = val; // Default to absolute value
|
|
42
|
+
let symbolOffset: (string | number)[] = [0, 0];
|
|
43
|
+
|
|
44
|
+
if (location === 'abovebar' || location === 'AboveBar' || location === 'ab') {
|
|
45
|
+
// Shape above the candle
|
|
46
|
+
if (candlestickData && candlestickData[i]) {
|
|
47
|
+
yValue = candlestickData[i].high;
|
|
48
|
+
}
|
|
49
|
+
symbolOffset = [0, '-150%']; // Shift up
|
|
50
|
+
} else if (location === 'belowbar' || location === 'BelowBar' || location === 'bl') {
|
|
51
|
+
// Shape below the candle
|
|
52
|
+
if (candlestickData && candlestickData[i]) {
|
|
53
|
+
yValue = candlestickData[i].low;
|
|
54
|
+
}
|
|
55
|
+
symbolOffset = [0, '150%']; // Shift down
|
|
56
|
+
} else if (location === 'top' || location === 'Top') {
|
|
57
|
+
// Shape at top of chart - we need to use a very high value
|
|
58
|
+
// This would require knowing the y-axis max, which we don't have here easily
|
|
59
|
+
// For now, use a placeholder approach - might need to calculate from data
|
|
60
|
+
// Or we can use a percentage of the viewport? ECharts doesn't support that directly in scatter.
|
|
61
|
+
// Best approach: use a large multiplier of current value or track max
|
|
62
|
+
// Simplified: use coordinate system max (will need enhancement)
|
|
63
|
+
yValue = val; // For now, keep absolute - would need axis max
|
|
64
|
+
symbolOffset = [0, 0];
|
|
65
|
+
} else if (location === 'bottom' || location === 'Bottom') {
|
|
66
|
+
// Shape at bottom of chart
|
|
67
|
+
yValue = val; // For now, keep absolute - would need axis min
|
|
68
|
+
symbolOffset = [0, 0];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const symbol = ShapeUtils.getShapeSymbol(shape);
|
|
72
|
+
const symbolSize = ShapeUtils.getShapeSize(size, width, height);
|
|
73
|
+
const rotate = ShapeUtils.getShapeRotation(shape);
|
|
74
|
+
|
|
75
|
+
// Special handling for labelup/down sizing - they contain text so they should be larger
|
|
76
|
+
let finalSize: number | number[] = symbolSize;
|
|
77
|
+
if (shape.includes('label')) {
|
|
78
|
+
// If custom size, scale it up for labels
|
|
79
|
+
if (Array.isArray(symbolSize)) {
|
|
80
|
+
finalSize = [symbolSize[0] * 2.5, symbolSize[1] * 2.5];
|
|
81
|
+
} else {
|
|
82
|
+
finalSize = symbolSize * 2.5;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Get label configuration based on location
|
|
87
|
+
const labelConfig = ShapeUtils.getLabelConfig(shape, location);
|
|
88
|
+
|
|
89
|
+
const item: any = {
|
|
90
|
+
value: [i, yValue],
|
|
91
|
+
symbol: symbol,
|
|
92
|
+
symbolSize: finalSize,
|
|
93
|
+
symbolRotate: rotate,
|
|
94
|
+
symbolOffset: symbolOffset,
|
|
95
|
+
itemStyle: {
|
|
96
|
+
color: color,
|
|
97
|
+
},
|
|
98
|
+
label: {
|
|
99
|
+
show: !!text,
|
|
100
|
+
position: labelConfig.position,
|
|
101
|
+
distance: labelConfig.distance,
|
|
102
|
+
formatter: text,
|
|
103
|
+
color: textColor,
|
|
104
|
+
fontSize: 10,
|
|
105
|
+
fontWeight: 'bold',
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
return item;
|
|
110
|
+
})
|
|
111
|
+
.filter((item) => item !== null);
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
name: seriesName,
|
|
115
|
+
type: 'scatter',
|
|
116
|
+
xAxisIndex: xAxisIndex,
|
|
117
|
+
yAxisIndex: yAxisIndex,
|
|
118
|
+
data: shapeData,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
}
|