@qfo/qfchart 0.6.8 → 0.7.2
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 +3 -0
- package/dist/qfchart.min.browser.js +16 -16
- package/dist/qfchart.min.es.js +16 -16
- package/package.json +2 -2
- package/src/QFChart.ts +52 -0
- package/src/components/LayoutManager.ts +682 -679
- package/src/components/SeriesBuilder.ts +20 -6
- package/src/components/SeriesRendererFactory.ts +4 -0
- package/src/components/TableOverlayRenderer.ts +322 -0
- package/src/components/renderers/BoxRenderer.ts +258 -0
- package/src/components/renderers/DrawingLineRenderer.ts +58 -52
- package/src/components/renderers/FillRenderer.ts +138 -31
- package/src/components/renderers/LabelRenderer.ts +9 -8
- package/src/components/renderers/LinefillRenderer.ts +60 -72
- package/src/components/renderers/PolylineRenderer.ts +197 -0
- package/src/components/renderers/ShapeRenderer.ts +121 -121
- package/src/utils/ColorUtils.ts +77 -32
- package/src/utils/ShapeUtils.ts +22 -14
|
@@ -158,6 +158,7 @@ export class SeriesBuilder {
|
|
|
158
158
|
// Prepare data arrays
|
|
159
159
|
// For 'fill' style, we don't use plot.data directly in the same way, but we initialize generic arrays
|
|
160
160
|
const dataArray = new Array(totalDataLength).fill(null);
|
|
161
|
+
const rawDataArray = new Array(totalDataLength).fill(null); // Unmodified values for fill references
|
|
161
162
|
const colorArray = new Array(totalDataLength).fill(null);
|
|
162
163
|
const optionsArray = new Array(totalDataLength).fill(null); // Store per-point options
|
|
163
164
|
|
|
@@ -171,27 +172,35 @@ export class SeriesBuilder {
|
|
|
171
172
|
let value = point.value;
|
|
172
173
|
const pointColor = point.options?.color;
|
|
173
174
|
|
|
174
|
-
//
|
|
175
|
+
// Always store the raw value for fill plots to reference
|
|
176
|
+
// (fills need the actual data even when the line is invisible via color=na)
|
|
177
|
+
rawDataArray[offsetIndex] = value;
|
|
178
|
+
|
|
179
|
+
// TradingView compatibility: if color is 'na' (NaN, null, undefined, or "na"), break the line
|
|
180
|
+
// When the options object explicitly has a 'color' key set to undefined,
|
|
181
|
+
// this means PineTS evaluated the color expression to na (hidden segment).
|
|
182
|
+
const hasExplicitColorKey = point.options != null && 'color' in point.options;
|
|
175
183
|
const isNaColor =
|
|
176
184
|
pointColor === null ||
|
|
177
185
|
pointColor === 'na' ||
|
|
178
186
|
pointColor === 'NaN' ||
|
|
179
|
-
(typeof pointColor === 'number' && isNaN(pointColor))
|
|
187
|
+
(typeof pointColor === 'number' && isNaN(pointColor)) ||
|
|
188
|
+
(hasExplicitColorKey && pointColor === undefined);
|
|
180
189
|
|
|
181
190
|
if (isNaColor) {
|
|
182
191
|
value = null;
|
|
183
192
|
}
|
|
184
193
|
|
|
185
194
|
dataArray[offsetIndex] = value;
|
|
186
|
-
colorArray[offsetIndex] = pointColor || plot.options.color || SeriesBuilder.DEFAULT_COLOR;
|
|
195
|
+
colorArray[offsetIndex] = isNaColor ? null : (pointColor || plot.options.color || SeriesBuilder.DEFAULT_COLOR);
|
|
187
196
|
optionsArray[offsetIndex] = point.options || {};
|
|
188
197
|
}
|
|
189
198
|
}
|
|
190
199
|
});
|
|
191
200
|
|
|
192
|
-
// Store data array for fill plots to reference
|
|
193
|
-
//
|
|
194
|
-
plotDataArrays.set(`${id}::${plotName}`,
|
|
201
|
+
// Store raw data array (before na-color nullification) for fill plots to reference
|
|
202
|
+
// Fill plots need the actual numeric values even when the referenced plot is invisible (color=na)
|
|
203
|
+
plotDataArrays.set(`${id}::${plotName}`, rawDataArray);
|
|
195
204
|
|
|
196
205
|
if (plot.options?.style?.startsWith('style_')) {
|
|
197
206
|
plot.options.style = plot.options.style.replace('style_', '') as IndicatorStyle;
|
|
@@ -223,6 +232,11 @@ export class SeriesBuilder {
|
|
|
223
232
|
return; // Skip rendering a series for barcolor
|
|
224
233
|
}
|
|
225
234
|
|
|
235
|
+
// Tables are rendered as DOM overlays, not ECharts series
|
|
236
|
+
if (plot.options.style === 'table') {
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
226
240
|
// Use Factory to get appropriate renderer
|
|
227
241
|
const renderer = SeriesRendererFactory.get(plot.options.style);
|
|
228
242
|
const seriesConfig = renderer.render({
|
|
@@ -10,6 +10,8 @@ import { FillRenderer } from './renderers/FillRenderer';
|
|
|
10
10
|
import { LabelRenderer } from './renderers/LabelRenderer';
|
|
11
11
|
import { DrawingLineRenderer } from './renderers/DrawingLineRenderer';
|
|
12
12
|
import { LinefillRenderer } from './renderers/LinefillRenderer';
|
|
13
|
+
import { PolylineRenderer } from './renderers/PolylineRenderer';
|
|
14
|
+
import { BoxRenderer } from './renderers/BoxRenderer';
|
|
13
15
|
|
|
14
16
|
export class SeriesRendererFactory {
|
|
15
17
|
private static renderers: Map<string, SeriesRenderer> = new Map();
|
|
@@ -30,6 +32,8 @@ export class SeriesRendererFactory {
|
|
|
30
32
|
this.register('label', new LabelRenderer());
|
|
31
33
|
this.register('drawing_line', new DrawingLineRenderer());
|
|
32
34
|
this.register('linefill', new LinefillRenderer());
|
|
35
|
+
this.register('drawing_polyline', new PolylineRenderer());
|
|
36
|
+
this.register('drawing_box', new BoxRenderer());
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
public static register(style: string, renderer: SeriesRenderer) {
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { ColorUtils } from '../utils/ColorUtils';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Renders Pine Script table objects as HTML DOM overlays positioned
|
|
5
|
+
* absolutely over the ECharts chart canvas.
|
|
6
|
+
*
|
|
7
|
+
* Tables use fixed positions (top_left, bottom_center, etc.) rather
|
|
8
|
+
* than data coordinates, so they are rendered as HTML elements instead
|
|
9
|
+
* of ECharts custom series.
|
|
10
|
+
*/
|
|
11
|
+
export class TableOverlayRenderer {
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Parse a color value for table rendering.
|
|
15
|
+
* Unlike ColorUtils.parseColor (which defaults to 0.3 opacity for fills),
|
|
16
|
+
* tables treat hex/named colors as fully opaque — only rgba provides opacity.
|
|
17
|
+
*/
|
|
18
|
+
private static safeParseColor(val: any): { color: string; opacity: number } {
|
|
19
|
+
if (!val || typeof val !== 'string') {
|
|
20
|
+
return { color: '#888888', opacity: 1 };
|
|
21
|
+
}
|
|
22
|
+
// Extract opacity from rgba(), otherwise assume fully opaque
|
|
23
|
+
const rgbaMatch = val.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
|
|
24
|
+
if (rgbaMatch) {
|
|
25
|
+
const a = rgbaMatch[4] ? parseFloat(rgbaMatch[4]) : 1;
|
|
26
|
+
return { color: `rgb(${rgbaMatch[1]},${rgbaMatch[2]},${rgbaMatch[3]})`, opacity: a };
|
|
27
|
+
}
|
|
28
|
+
return { color: val, opacity: 1 };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Clear all existing table overlays and render new ones.
|
|
33
|
+
* @param gridRect The ECharts grid rect {x, y, width, height} in pixels,
|
|
34
|
+
* representing the actual plot area within the container.
|
|
35
|
+
*/
|
|
36
|
+
static render(container: HTMLElement, tables: any[], gridRect?: { x: number; y: number; width: number; height: number }): void {
|
|
37
|
+
TableOverlayRenderer.clearAll(container);
|
|
38
|
+
|
|
39
|
+
// Pine Script: only the last table at each position is displayed
|
|
40
|
+
const byPosition = new Map<string, any>();
|
|
41
|
+
for (const tbl of tables) {
|
|
42
|
+
if (tbl && !tbl._deleted) {
|
|
43
|
+
byPosition.set(tbl.position, tbl);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
byPosition.forEach((tbl) => {
|
|
48
|
+
const el = TableOverlayRenderer.buildTable(tbl);
|
|
49
|
+
TableOverlayRenderer.positionTable(el, tbl.position, gridRect);
|
|
50
|
+
container.appendChild(el);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
static clearAll(container: HTMLElement): void {
|
|
55
|
+
while (container.firstChild) {
|
|
56
|
+
container.removeChild(container.firstChild);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private static buildTable(tbl: any): HTMLElement {
|
|
61
|
+
const table = document.createElement('table');
|
|
62
|
+
table.style.borderCollapse = 'separate';
|
|
63
|
+
table.style.borderSpacing = '0';
|
|
64
|
+
table.style.pointerEvents = 'auto';
|
|
65
|
+
table.style.fontSize = '14px';
|
|
66
|
+
table.style.lineHeight = '1.4';
|
|
67
|
+
table.style.fontFamily = 'sans-serif';
|
|
68
|
+
table.style.margin = '4px';
|
|
69
|
+
|
|
70
|
+
// Table background
|
|
71
|
+
if (tbl.bgcolor) {
|
|
72
|
+
const { color, opacity } = TableOverlayRenderer.safeParseColor(tbl.bgcolor);
|
|
73
|
+
table.style.backgroundColor = color;
|
|
74
|
+
if (opacity < 1) table.style.opacity = String(opacity);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Frame (outer border)
|
|
78
|
+
if (tbl.frame_width > 0 && tbl.frame_color) {
|
|
79
|
+
const { color: fc } = TableOverlayRenderer.safeParseColor(tbl.frame_color);
|
|
80
|
+
table.style.border = `${tbl.frame_width}px solid ${fc}`;
|
|
81
|
+
} else if (tbl.frame_width > 0) {
|
|
82
|
+
table.style.border = `${tbl.frame_width}px solid #999`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Build merge lookup: for each cell, determine colspan/rowspan
|
|
86
|
+
const mergeMap = new Map<string, { colspan: number; rowspan: number }>();
|
|
87
|
+
const mergedCells = new Set<string>();
|
|
88
|
+
|
|
89
|
+
if (tbl.merges) {
|
|
90
|
+
for (const m of tbl.merges) {
|
|
91
|
+
const key = `${m.startCol},${m.startRow}`;
|
|
92
|
+
mergeMap.set(key, {
|
|
93
|
+
colspan: m.endCol - m.startCol + 1,
|
|
94
|
+
rowspan: m.endRow - m.startRow + 1,
|
|
95
|
+
});
|
|
96
|
+
// Mark all cells covered by this merge (except the origin)
|
|
97
|
+
for (let r = m.startRow; r <= m.endRow; r++) {
|
|
98
|
+
for (let c = m.startCol; c <= m.endCol; c++) {
|
|
99
|
+
if (r === m.startRow && c === m.startCol) continue;
|
|
100
|
+
mergedCells.add(`${c},${r}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Build rows
|
|
107
|
+
const rows = tbl.rows || 0;
|
|
108
|
+
const cols = tbl.columns || 0;
|
|
109
|
+
|
|
110
|
+
for (let r = 0; r < rows; r++) {
|
|
111
|
+
const tr = document.createElement('tr');
|
|
112
|
+
|
|
113
|
+
for (let c = 0; c < cols; c++) {
|
|
114
|
+
const cellKey = `${c},${r}`;
|
|
115
|
+
|
|
116
|
+
// Skip cells that are covered by a merge
|
|
117
|
+
if (mergedCells.has(cellKey)) continue;
|
|
118
|
+
|
|
119
|
+
const td = document.createElement('td');
|
|
120
|
+
|
|
121
|
+
// Apply merge attributes
|
|
122
|
+
const merge = mergeMap.get(cellKey);
|
|
123
|
+
if (merge) {
|
|
124
|
+
if (merge.colspan > 1) td.colSpan = merge.colspan;
|
|
125
|
+
if (merge.rowspan > 1) td.rowSpan = merge.rowspan;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Cell borders
|
|
129
|
+
if (tbl.border_width > 0) {
|
|
130
|
+
const bc = tbl.border_color
|
|
131
|
+
? TableOverlayRenderer.safeParseColor(tbl.border_color).color
|
|
132
|
+
: '#999';
|
|
133
|
+
td.style.border = `${tbl.border_width}px solid ${bc}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Get cell data
|
|
137
|
+
const cellData = tbl.cells?.[r]?.[c];
|
|
138
|
+
if (cellData && !cellData._merged) {
|
|
139
|
+
// Cell text
|
|
140
|
+
td.textContent = cellData.text || '';
|
|
141
|
+
|
|
142
|
+
// Cell background
|
|
143
|
+
if (cellData.bgcolor) {
|
|
144
|
+
const { color: bg, opacity: bgOp } = TableOverlayRenderer.safeParseColor(cellData.bgcolor);
|
|
145
|
+
td.style.backgroundColor = bg;
|
|
146
|
+
if (bgOp < 1) {
|
|
147
|
+
// Use rgba for cell-level opacity to avoid affecting text
|
|
148
|
+
td.style.backgroundColor = cellData.bgcolor;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Text color
|
|
153
|
+
if (cellData.text_color) {
|
|
154
|
+
const { color: tc } = TableOverlayRenderer.safeParseColor(cellData.text_color);
|
|
155
|
+
td.style.color = tc;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Text size
|
|
159
|
+
td.style.fontSize = TableOverlayRenderer.getSizePixels(cellData.text_size) + 'px';
|
|
160
|
+
|
|
161
|
+
// Text alignment
|
|
162
|
+
td.style.textAlign = TableOverlayRenderer.mapHAlign(cellData.text_halign);
|
|
163
|
+
td.style.verticalAlign = TableOverlayRenderer.mapVAlign(cellData.text_valign);
|
|
164
|
+
|
|
165
|
+
// Font family
|
|
166
|
+
if (cellData.text_font_family === 'monospace') {
|
|
167
|
+
td.style.fontFamily = 'monospace';
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Width/height (% of chart area, 0 = auto)
|
|
171
|
+
if (cellData.width > 0) {
|
|
172
|
+
td.style.width = cellData.width + '%';
|
|
173
|
+
}
|
|
174
|
+
if (cellData.height > 0) {
|
|
175
|
+
td.style.height = cellData.height + '%';
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Tooltip
|
|
179
|
+
if (cellData.tooltip) {
|
|
180
|
+
td.title = cellData.tooltip;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Default padding
|
|
185
|
+
td.style.padding = '4px 6px';
|
|
186
|
+
td.style.whiteSpace = 'nowrap';
|
|
187
|
+
|
|
188
|
+
tr.appendChild(td);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
table.appendChild(tr);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return table;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private static positionTable(
|
|
198
|
+
el: HTMLElement,
|
|
199
|
+
position: string,
|
|
200
|
+
gridRect?: { x: number; y: number; width: number; height: number },
|
|
201
|
+
): void {
|
|
202
|
+
el.style.position = 'absolute';
|
|
203
|
+
|
|
204
|
+
// Use grid rect (actual plot area) if available, otherwise fall back to container edges.
|
|
205
|
+
// Inset bottom/right by a few pixels so tables don't touch the axis lines.
|
|
206
|
+
const PAD = 8;
|
|
207
|
+
const top = gridRect ? gridRect.y + 'px' : '0';
|
|
208
|
+
const left = gridRect ? gridRect.x + 'px' : '0';
|
|
209
|
+
const bottom = gridRect ? (gridRect.y + gridRect.height - PAD) + 'px' : '0';
|
|
210
|
+
const right = gridRect ? (gridRect.x + gridRect.width - PAD) + 'px' : '0';
|
|
211
|
+
const centerX = gridRect ? (gridRect.x + gridRect.width / 2) + 'px' : '50%';
|
|
212
|
+
const centerY = gridRect ? (gridRect.y + gridRect.height / 2) + 'px' : '50%';
|
|
213
|
+
|
|
214
|
+
switch (position) {
|
|
215
|
+
case 'top_left':
|
|
216
|
+
el.style.top = top;
|
|
217
|
+
el.style.left = left;
|
|
218
|
+
break;
|
|
219
|
+
case 'top_center':
|
|
220
|
+
el.style.top = top;
|
|
221
|
+
el.style.left = centerX;
|
|
222
|
+
el.style.transform = 'translateX(-50%)';
|
|
223
|
+
break;
|
|
224
|
+
case 'top_right':
|
|
225
|
+
el.style.top = top;
|
|
226
|
+
el.style.left = right;
|
|
227
|
+
el.style.transform = 'translateX(-100%)';
|
|
228
|
+
break;
|
|
229
|
+
case 'middle_left':
|
|
230
|
+
el.style.top = centerY;
|
|
231
|
+
el.style.left = left;
|
|
232
|
+
el.style.transform = 'translateY(-50%)';
|
|
233
|
+
break;
|
|
234
|
+
case 'middle_center':
|
|
235
|
+
el.style.top = centerY;
|
|
236
|
+
el.style.left = centerX;
|
|
237
|
+
el.style.transform = 'translate(-50%, -50%)';
|
|
238
|
+
break;
|
|
239
|
+
case 'middle_right':
|
|
240
|
+
el.style.top = centerY;
|
|
241
|
+
el.style.left = right;
|
|
242
|
+
el.style.transform = 'translate(-100%, -50%)';
|
|
243
|
+
break;
|
|
244
|
+
case 'bottom_left':
|
|
245
|
+
el.style.top = bottom;
|
|
246
|
+
el.style.left = left;
|
|
247
|
+
el.style.transform = 'translateY(-100%)';
|
|
248
|
+
break;
|
|
249
|
+
case 'bottom_center':
|
|
250
|
+
el.style.top = bottom;
|
|
251
|
+
el.style.left = centerX;
|
|
252
|
+
el.style.transform = 'translate(-50%, -100%)';
|
|
253
|
+
break;
|
|
254
|
+
case 'bottom_right':
|
|
255
|
+
el.style.top = bottom;
|
|
256
|
+
el.style.left = right;
|
|
257
|
+
el.style.transform = 'translate(-100%, -100%)';
|
|
258
|
+
break;
|
|
259
|
+
default:
|
|
260
|
+
el.style.top = top;
|
|
261
|
+
el.style.left = right;
|
|
262
|
+
el.style.transform = 'translateX(-100%)';
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private static getSizePixels(size: string | number): number {
|
|
268
|
+
if (typeof size === 'number' && size > 0) return size;
|
|
269
|
+
switch (size) {
|
|
270
|
+
case 'auto':
|
|
271
|
+
case 'size.auto':
|
|
272
|
+
return 12;
|
|
273
|
+
case 'tiny':
|
|
274
|
+
case 'size.tiny':
|
|
275
|
+
return 8;
|
|
276
|
+
case 'small':
|
|
277
|
+
case 'size.small':
|
|
278
|
+
return 10;
|
|
279
|
+
case 'normal':
|
|
280
|
+
case 'size.normal':
|
|
281
|
+
return 14;
|
|
282
|
+
case 'large':
|
|
283
|
+
case 'size.large':
|
|
284
|
+
return 20;
|
|
285
|
+
case 'huge':
|
|
286
|
+
case 'size.huge':
|
|
287
|
+
return 36;
|
|
288
|
+
default:
|
|
289
|
+
return 14;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private static mapHAlign(align: string): string {
|
|
294
|
+
switch (align) {
|
|
295
|
+
case 'left':
|
|
296
|
+
case 'text.align_left':
|
|
297
|
+
return 'left';
|
|
298
|
+
case 'right':
|
|
299
|
+
case 'text.align_right':
|
|
300
|
+
return 'right';
|
|
301
|
+
case 'center':
|
|
302
|
+
case 'text.align_center':
|
|
303
|
+
default:
|
|
304
|
+
return 'center';
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private static mapVAlign(align: string): string {
|
|
309
|
+
switch (align) {
|
|
310
|
+
case 'top':
|
|
311
|
+
case 'text.align_top':
|
|
312
|
+
return 'top';
|
|
313
|
+
case 'bottom':
|
|
314
|
+
case 'text.align_bottom':
|
|
315
|
+
return 'bottom';
|
|
316
|
+
case 'center':
|
|
317
|
+
case 'text.align_center':
|
|
318
|
+
default:
|
|
319
|
+
return 'middle';
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { SeriesRenderer, RenderContext } from './SeriesRenderer';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Convert any color string to a format ECharts canvas can render with opacity.
|
|
5
|
+
* 8-digit hex (#RRGGBBAA) is not universally supported by canvas — convert to rgba().
|
|
6
|
+
*/
|
|
7
|
+
function normalizeColor(color: string | undefined): string | undefined {
|
|
8
|
+
if (!color || typeof color !== 'string') return color;
|
|
9
|
+
if (color.startsWith('#')) {
|
|
10
|
+
const hex = color.slice(1);
|
|
11
|
+
if (hex.length === 8) {
|
|
12
|
+
const r = parseInt(hex.slice(0, 2), 16);
|
|
13
|
+
const g = parseInt(hex.slice(2, 4), 16);
|
|
14
|
+
const b = parseInt(hex.slice(4, 6), 16);
|
|
15
|
+
const a = parseInt(hex.slice(6, 8), 16) / 255;
|
|
16
|
+
return `rgba(${r},${g},${b},${a.toFixed(3)})`;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return color;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Renderer for Pine Script box.* drawing objects.
|
|
24
|
+
* Each box is defined by two corners (left,top) → (right,bottom)
|
|
25
|
+
* with fill, border, optional text, and optional extend.
|
|
26
|
+
*
|
|
27
|
+
* Style name: 'drawing_box' (distinct from other styles).
|
|
28
|
+
*/
|
|
29
|
+
export class BoxRenderer implements SeriesRenderer {
|
|
30
|
+
render(context: RenderContext): any {
|
|
31
|
+
const { seriesName, xAxisIndex, yAxisIndex, dataArray, dataIndexOffset } = context;
|
|
32
|
+
const offset = dataIndexOffset || 0;
|
|
33
|
+
|
|
34
|
+
// Collect all non-deleted box objects from the sparse dataArray.
|
|
35
|
+
const boxObjects: any[] = [];
|
|
36
|
+
|
|
37
|
+
for (let i = 0; i < dataArray.length; i++) {
|
|
38
|
+
const val = dataArray[i];
|
|
39
|
+
if (!val) continue;
|
|
40
|
+
|
|
41
|
+
const items = Array.isArray(val) ? val : [val];
|
|
42
|
+
for (const bx of items) {
|
|
43
|
+
if (bx && typeof bx === 'object' && !bx._deleted) {
|
|
44
|
+
boxObjects.push(bx);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (boxObjects.length === 0) {
|
|
50
|
+
return { name: seriesName, type: 'custom', xAxisIndex, yAxisIndex, data: [], silent: true };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Compute y-range for axis scaling
|
|
54
|
+
let yMin = Infinity, yMax = -Infinity;
|
|
55
|
+
for (const bx of boxObjects) {
|
|
56
|
+
if (bx.top < yMin) yMin = bx.top;
|
|
57
|
+
if (bx.top > yMax) yMax = bx.top;
|
|
58
|
+
if (bx.bottom < yMin) yMin = bx.bottom;
|
|
59
|
+
if (bx.bottom > yMax) yMax = bx.bottom;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Use a SINGLE data entry spanning the full x-range so renderItem is always called.
|
|
63
|
+
// ECharts filters a data item only when ALL its x-dimensions are on the same side
|
|
64
|
+
// of the visible window. With dims 0=0 and 1=lastBar the item always straddles
|
|
65
|
+
// the viewport, so renderItem fires exactly once regardless of scroll position.
|
|
66
|
+
// Dims 2/3 are yMin/yMax for axis scaling.
|
|
67
|
+
const totalBars = (context.candlestickData?.length || 0) + offset;
|
|
68
|
+
const lastBarIndex = Math.max(0, totalBars - 1);
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
name: seriesName,
|
|
72
|
+
type: 'custom',
|
|
73
|
+
xAxisIndex,
|
|
74
|
+
yAxisIndex,
|
|
75
|
+
renderItem: (params: any, api: any) => {
|
|
76
|
+
const children: any[] = [];
|
|
77
|
+
|
|
78
|
+
for (const bx of boxObjects) {
|
|
79
|
+
if (bx._deleted) continue;
|
|
80
|
+
|
|
81
|
+
const xOff = (bx.xloc === 'bar_index' || bx.xloc === 'bi') ? offset : 0;
|
|
82
|
+
const pTopLeft = api.coord([bx.left + xOff, bx.top]);
|
|
83
|
+
const pBottomRight = api.coord([bx.right + xOff, bx.bottom]);
|
|
84
|
+
|
|
85
|
+
let x = pTopLeft[0];
|
|
86
|
+
let y = pTopLeft[1];
|
|
87
|
+
let w = pBottomRight[0] - pTopLeft[0];
|
|
88
|
+
let h = pBottomRight[1] - pTopLeft[1];
|
|
89
|
+
|
|
90
|
+
// Handle extend (horizontal borders)
|
|
91
|
+
const extend = bx.extend || 'none';
|
|
92
|
+
if (extend !== 'none') {
|
|
93
|
+
const cs = params.coordSys;
|
|
94
|
+
if (extend === 'left' || extend === 'both') {
|
|
95
|
+
x = cs.x;
|
|
96
|
+
w = (extend === 'both') ? cs.width : (pBottomRight[0] - cs.x);
|
|
97
|
+
}
|
|
98
|
+
if (extend === 'right' || extend === 'both') {
|
|
99
|
+
if (extend === 'right') {
|
|
100
|
+
w = cs.x + cs.width - pTopLeft[0];
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Background fill rect
|
|
106
|
+
const bgColor = normalizeColor(bx.bgcolor) || '#2962ff';
|
|
107
|
+
children.push({
|
|
108
|
+
type: 'rect',
|
|
109
|
+
shape: { x, y, width: w, height: h },
|
|
110
|
+
style: { fill: bgColor },
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Border rect (on top of fill)
|
|
114
|
+
const borderColor = normalizeColor(bx.border_color) || '#2962ff';
|
|
115
|
+
const borderWidth = bx.border_width ?? 1;
|
|
116
|
+
if (borderWidth > 0) {
|
|
117
|
+
children.push({
|
|
118
|
+
type: 'rect',
|
|
119
|
+
shape: { x, y, width: w, height: h },
|
|
120
|
+
style: {
|
|
121
|
+
fill: 'none',
|
|
122
|
+
stroke: borderColor,
|
|
123
|
+
lineWidth: borderWidth,
|
|
124
|
+
lineDash: this.getDashPattern(bx.border_style),
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Text inside box
|
|
130
|
+
if (bx.text) {
|
|
131
|
+
const textX = this.getTextX(x, w, bx.text_halign);
|
|
132
|
+
const textY = this.getTextY(y, h, bx.text_valign);
|
|
133
|
+
children.push({
|
|
134
|
+
type: 'text',
|
|
135
|
+
style: {
|
|
136
|
+
x: textX,
|
|
137
|
+
y: textY,
|
|
138
|
+
text: bx.text,
|
|
139
|
+
fill: normalizeColor(bx.text_color) || '#000000',
|
|
140
|
+
fontSize: this.getSizePixels(bx.text_size),
|
|
141
|
+
fontFamily: bx.text_font_family === 'monospace' ? 'monospace' : 'sans-serif',
|
|
142
|
+
fontWeight: (bx.text_formatting === 'format_bold') ? 'bold' : 'normal',
|
|
143
|
+
fontStyle: (bx.text_formatting === 'format_italic') ? 'italic' : 'normal',
|
|
144
|
+
textAlign: this.mapHAlign(bx.text_halign),
|
|
145
|
+
textVerticalAlign: this.mapVAlign(bx.text_valign),
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return { type: 'group', children };
|
|
152
|
+
},
|
|
153
|
+
data: [[0, lastBarIndex, yMin, yMax]],
|
|
154
|
+
clip: true,
|
|
155
|
+
encode: { x: [0, 1], y: [2, 3] },
|
|
156
|
+
z: 14,
|
|
157
|
+
silent: true,
|
|
158
|
+
emphasis: { disabled: true },
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private getDashPattern(style: string): number[] | undefined {
|
|
163
|
+
switch (style) {
|
|
164
|
+
case 'style_dotted':
|
|
165
|
+
return [2, 2];
|
|
166
|
+
case 'style_dashed':
|
|
167
|
+
return [6, 4];
|
|
168
|
+
default:
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private getSizePixels(size: string | number): number {
|
|
174
|
+
if (typeof size === 'number' && size > 0) return size;
|
|
175
|
+
switch (size) {
|
|
176
|
+
case 'auto':
|
|
177
|
+
case 'size.auto':
|
|
178
|
+
return 12;
|
|
179
|
+
case 'tiny':
|
|
180
|
+
case 'size.tiny':
|
|
181
|
+
return 8;
|
|
182
|
+
case 'small':
|
|
183
|
+
case 'size.small':
|
|
184
|
+
return 10;
|
|
185
|
+
case 'normal':
|
|
186
|
+
case 'size.normal':
|
|
187
|
+
return 14;
|
|
188
|
+
case 'large':
|
|
189
|
+
case 'size.large':
|
|
190
|
+
return 20;
|
|
191
|
+
case 'huge':
|
|
192
|
+
case 'size.huge':
|
|
193
|
+
return 36;
|
|
194
|
+
default:
|
|
195
|
+
return 12;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private mapHAlign(align: string): string {
|
|
200
|
+
switch (align) {
|
|
201
|
+
case 'left':
|
|
202
|
+
case 'text.align_left':
|
|
203
|
+
return 'left';
|
|
204
|
+
case 'right':
|
|
205
|
+
case 'text.align_right':
|
|
206
|
+
return 'right';
|
|
207
|
+
case 'center':
|
|
208
|
+
case 'text.align_center':
|
|
209
|
+
default:
|
|
210
|
+
return 'center';
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private mapVAlign(align: string): string {
|
|
215
|
+
switch (align) {
|
|
216
|
+
case 'top':
|
|
217
|
+
case 'text.align_top':
|
|
218
|
+
return 'top';
|
|
219
|
+
case 'bottom':
|
|
220
|
+
case 'text.align_bottom':
|
|
221
|
+
return 'bottom';
|
|
222
|
+
case 'center':
|
|
223
|
+
case 'text.align_center':
|
|
224
|
+
default:
|
|
225
|
+
return 'middle';
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private getTextX(x: number, w: number, halign: string): number {
|
|
230
|
+
switch (halign) {
|
|
231
|
+
case 'left':
|
|
232
|
+
case 'text.align_left':
|
|
233
|
+
return x + 4;
|
|
234
|
+
case 'right':
|
|
235
|
+
case 'text.align_right':
|
|
236
|
+
return x + w - 4;
|
|
237
|
+
case 'center':
|
|
238
|
+
case 'text.align_center':
|
|
239
|
+
default:
|
|
240
|
+
return x + w / 2;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private getTextY(y: number, h: number, valign: string): number {
|
|
245
|
+
switch (valign) {
|
|
246
|
+
case 'top':
|
|
247
|
+
case 'text.align_top':
|
|
248
|
+
return y + 4;
|
|
249
|
+
case 'bottom':
|
|
250
|
+
case 'text.align_bottom':
|
|
251
|
+
return y + h - 4;
|
|
252
|
+
case 'center':
|
|
253
|
+
case 'text.align_center':
|
|
254
|
+
default:
|
|
255
|
+
return y + h / 2;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|