@qfo/qfchart 0.7.2 → 0.8.0
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 +54 -2
- package/dist/qfchart.min.browser.js +16 -14
- package/dist/qfchart.min.es.js +16 -14
- package/package.json +1 -1
- package/src/QFChart.ts +533 -58
- package/src/components/GraphicBuilder.ts +284 -263
- package/src/components/LayoutManager.ts +67 -24
- package/src/components/SeriesBuilder.ts +122 -1
- package/src/components/TableCanvasRenderer.ts +467 -0
- package/src/components/TableOverlayRenderer.ts +76 -24
- package/src/components/TooltipFormatter.ts +97 -97
- package/src/components/renderers/BackgroundRenderer.ts +59 -47
- package/src/components/renderers/BoxRenderer.ts +133 -37
- package/src/components/renderers/DrawingLineRenderer.ts +12 -16
- package/src/components/renderers/FillRenderer.ts +118 -3
- package/src/components/renderers/HistogramRenderer.ts +67 -20
- package/src/components/renderers/LabelRenderer.ts +35 -9
- package/src/components/renderers/LinefillRenderer.ts +4 -12
- package/src/components/renderers/OHLCBarRenderer.ts +171 -161
- package/src/components/renderers/PolylineRenderer.ts +32 -32
- package/src/types.ts +11 -2
- package/src/utils/ColorUtils.ts +1 -1
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renders Pine Script table objects as ECharts graphic elements on the canvas.
|
|
3
|
+
*
|
|
4
|
+
* Instead of DOM overlays (TableOverlayRenderer), this renderer produces
|
|
5
|
+
* flat ECharts graphic elements — rects + texts — that are drawn directly
|
|
6
|
+
* on the canvas alongside charts. This provides:
|
|
7
|
+
*
|
|
8
|
+
* - Pixel-perfect sizing (Pine Script % maps directly to px via gridRect)
|
|
9
|
+
* - Single render pipeline (participates in ECharts export, animation, resize)
|
|
10
|
+
* - Better performance for large tables (5000+ cells as canvas rects vs DOM nodes)
|
|
11
|
+
* - Correct z-ordering with other chart elements
|
|
12
|
+
*
|
|
13
|
+
* Note: ECharts' graphic merge (`notMerge: false`) does not preserve nested
|
|
14
|
+
* group→children hierarchies. All elements are therefore emitted as flat,
|
|
15
|
+
* absolute-positioned top-level elements.
|
|
16
|
+
*
|
|
17
|
+
* All coordinates are Math.round()'d to avoid sub-pixel gaps between adjacent cells.
|
|
18
|
+
*/
|
|
19
|
+
export class TableCanvasRenderer {
|
|
20
|
+
|
|
21
|
+
// ── Color Parsing ──────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
private static parseColor(val: any): { color: string; opacity: number } {
|
|
24
|
+
if (!val || typeof val !== 'string' || val.length === 0) {
|
|
25
|
+
return { color: '', opacity: 0 };
|
|
26
|
+
}
|
|
27
|
+
const rgbaMatch = val.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
|
|
28
|
+
if (rgbaMatch) {
|
|
29
|
+
const a = rgbaMatch[4] ? parseFloat(rgbaMatch[4]) : 1;
|
|
30
|
+
return { color: `rgb(${rgbaMatch[1]},${rgbaMatch[2]},${rgbaMatch[3]})`, opacity: a };
|
|
31
|
+
}
|
|
32
|
+
if (/^#[0-9a-fA-F]{8}$/.test(val)) {
|
|
33
|
+
const r = parseInt(val.slice(1, 3), 16);
|
|
34
|
+
const g = parseInt(val.slice(3, 5), 16);
|
|
35
|
+
const b = parseInt(val.slice(5, 7), 16);
|
|
36
|
+
const a = parseInt(val.slice(7, 9), 16) / 255;
|
|
37
|
+
return { color: `rgb(${r},${g},${b})`, opacity: a };
|
|
38
|
+
}
|
|
39
|
+
return { color: val, opacity: 1 };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── Size / Alignment Mapping ───────────────────────────────
|
|
43
|
+
// TradingView reference sizes (approximate px at 1× DPR)
|
|
44
|
+
|
|
45
|
+
private static getSizePixels(size: string | number): number {
|
|
46
|
+
if (typeof size === 'number' && size > 0) return size;
|
|
47
|
+
switch (size) {
|
|
48
|
+
case 'auto': case 'size.auto': return 11;
|
|
49
|
+
case 'tiny': case 'size.tiny': return 8;
|
|
50
|
+
case 'small': case 'size.small': return 10;
|
|
51
|
+
case 'normal':case 'size.normal': return 12;
|
|
52
|
+
case 'large': case 'size.large': return 16;
|
|
53
|
+
case 'huge': case 'size.huge': return 24;
|
|
54
|
+
default: return 12;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private static mapHAlign(align: string): 'left' | 'center' | 'right' {
|
|
59
|
+
switch (align) {
|
|
60
|
+
case 'left': case 'text.align_left': return 'left';
|
|
61
|
+
case 'right': case 'text.align_right': return 'right';
|
|
62
|
+
default: return 'center';
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private static mapVAlign(align: string): 'top' | 'middle' | 'bottom' {
|
|
67
|
+
switch (align) {
|
|
68
|
+
case 'top': case 'text.align_top': return 'top';
|
|
69
|
+
case 'bottom': case 'text.align_bottom': return 'bottom';
|
|
70
|
+
default: return 'middle';
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── Main Entry Point ──────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Build flat ECharts graphic elements for all tables.
|
|
78
|
+
* Returns an array of rect/text elements with absolute positions.
|
|
79
|
+
*/
|
|
80
|
+
static buildGraphicElements(
|
|
81
|
+
tables: any[],
|
|
82
|
+
getGridRect: (paneIndex: number) => { x: number; y: number; width: number; height: number } | undefined,
|
|
83
|
+
): any[] {
|
|
84
|
+
if (!tables || tables.length === 0) return [];
|
|
85
|
+
|
|
86
|
+
// Pine Script: only the last table at each position is displayed
|
|
87
|
+
const byPosition = new Map<string, any>();
|
|
88
|
+
for (const tbl of tables) {
|
|
89
|
+
if (tbl && !tbl._deleted) {
|
|
90
|
+
byPosition.set(tbl.position, tbl);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const elements: any[] = [];
|
|
95
|
+
byPosition.forEach((tbl) => {
|
|
96
|
+
const paneIndex = tbl._paneIndex ?? 0;
|
|
97
|
+
const gridRect = getGridRect(paneIndex);
|
|
98
|
+
if (!gridRect) return;
|
|
99
|
+
|
|
100
|
+
const tableElements = TableCanvasRenderer.buildTableElements(tbl, gridRect);
|
|
101
|
+
elements.push(...tableElements);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return elements;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Table Layout Engine ──────────────────────────────────
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Measure and layout a table, producing flat absolute-positioned elements.
|
|
111
|
+
* Returns an array of ECharts graphic rect/text elements.
|
|
112
|
+
*/
|
|
113
|
+
private static buildTableElements(
|
|
114
|
+
tbl: any,
|
|
115
|
+
gridRect: { x: number; y: number; width: number; height: number },
|
|
116
|
+
): any[] {
|
|
117
|
+
const rows = tbl.rows || 0;
|
|
118
|
+
const cols = tbl.columns || 0;
|
|
119
|
+
if (rows === 0 || cols === 0) return [];
|
|
120
|
+
|
|
121
|
+
const borderWidth = tbl.border_width ?? 0;
|
|
122
|
+
const frameWidth = tbl.frame_width ?? 0;
|
|
123
|
+
const hasCellBorders = borderWidth > 0 && !!tbl.border_color;
|
|
124
|
+
const hasFrame = frameWidth > 0 && !!tbl.frame_color;
|
|
125
|
+
|
|
126
|
+
// ── Build merge lookup ──
|
|
127
|
+
const mergeMap = new Map<string, { colspan: number; rowspan: number }>();
|
|
128
|
+
const mergedCells = new Set<string>();
|
|
129
|
+
if (tbl.merges) {
|
|
130
|
+
for (const m of tbl.merges) {
|
|
131
|
+
mergeMap.set(`${m.startCol},${m.startRow}`, {
|
|
132
|
+
colspan: m.endCol - m.startCol + 1,
|
|
133
|
+
rowspan: m.endRow - m.startRow + 1,
|
|
134
|
+
});
|
|
135
|
+
for (let r = m.startRow; r <= m.endRow; r++) {
|
|
136
|
+
for (let c = m.startCol; c <= m.endCol; c++) {
|
|
137
|
+
if (r === m.startRow && c === m.startCol) continue;
|
|
138
|
+
mergedCells.add(`${c},${r}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const PAD_X = 4;
|
|
145
|
+
const PAD_Y = 2;
|
|
146
|
+
const LINE_HEIGHT = 1.25; // Multiplier for line height (tighter than 1.4)
|
|
147
|
+
|
|
148
|
+
// ── Phase 1: Measure each cell ──
|
|
149
|
+
type CellInfo = {
|
|
150
|
+
text: string; lines: string[]; fontSize: number; fontFamily: string;
|
|
151
|
+
textColor: { color: string; opacity: number };
|
|
152
|
+
bgColor: { color: string; opacity: number };
|
|
153
|
+
halign: 'left' | 'center' | 'right';
|
|
154
|
+
valign: 'top' | 'middle' | 'bottom';
|
|
155
|
+
explicitWidth: number; explicitHeight: number;
|
|
156
|
+
colspan: number; rowspan: number; skip: boolean;
|
|
157
|
+
padX: number; padY: number;
|
|
158
|
+
};
|
|
159
|
+
const cellInfos: CellInfo[][] = [];
|
|
160
|
+
|
|
161
|
+
for (let r = 0; r < rows; r++) {
|
|
162
|
+
cellInfos[r] = [];
|
|
163
|
+
for (let c = 0; c < cols; c++) {
|
|
164
|
+
if (mergedCells.has(`${c},${r}`)) {
|
|
165
|
+
cellInfos[r][c] = {
|
|
166
|
+
text: '', lines: [], fontSize: 12, fontFamily: 'sans-serif',
|
|
167
|
+
textColor: { color: '', opacity: 0 }, bgColor: { color: '', opacity: 0 },
|
|
168
|
+
halign: 'center', valign: 'middle',
|
|
169
|
+
explicitWidth: 0, explicitHeight: 0,
|
|
170
|
+
colspan: 1, rowspan: 1, skip: true, padX: 0, padY: 0,
|
|
171
|
+
};
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const cellData = tbl.cells?.[r]?.[c];
|
|
176
|
+
const merge = mergeMap.get(`${c},${r}`);
|
|
177
|
+
const colspan = merge?.colspan ?? 1;
|
|
178
|
+
const rowspan = merge?.rowspan ?? 1;
|
|
179
|
+
|
|
180
|
+
const text = (cellData && !cellData._merged) ? (cellData.text || '') : '';
|
|
181
|
+
const lines = text ? text.split('\n') : [];
|
|
182
|
+
const fontSize = cellData ? TableCanvasRenderer.getSizePixels(cellData.text_size) : 12;
|
|
183
|
+
const fontFamily = cellData?.text_font_family === 'monospace' ? 'monospace' : 'sans-serif';
|
|
184
|
+
|
|
185
|
+
let explicitWidth = 0;
|
|
186
|
+
let explicitHeight = 0;
|
|
187
|
+
if (cellData?.width > 0) explicitWidth = Math.max(1, cellData.width * gridRect.width / 100);
|
|
188
|
+
if (cellData?.height > 0) explicitHeight = Math.max(1, cellData.height * gridRect.height / 100);
|
|
189
|
+
|
|
190
|
+
const isTiny = explicitHeight > 0 && explicitHeight < 4;
|
|
191
|
+
const padX = isTiny ? 0 : PAD_X;
|
|
192
|
+
const padY = isTiny ? 0 : PAD_Y;
|
|
193
|
+
|
|
194
|
+
const bgRaw = (cellData && !cellData._merged && cellData.bgcolor &&
|
|
195
|
+
typeof cellData.bgcolor === 'string' && cellData.bgcolor.length > 0)
|
|
196
|
+
? cellData.bgcolor : '';
|
|
197
|
+
const textColorRaw = cellData?.text_color || '';
|
|
198
|
+
|
|
199
|
+
cellInfos[r][c] = {
|
|
200
|
+
text, lines, fontSize, fontFamily,
|
|
201
|
+
textColor: textColorRaw ? TableCanvasRenderer.parseColor(textColorRaw) : { color: '#e0e0e0', opacity: 1 },
|
|
202
|
+
bgColor: bgRaw ? TableCanvasRenderer.parseColor(bgRaw) : { color: '', opacity: 0 },
|
|
203
|
+
halign: cellData ? TableCanvasRenderer.mapHAlign(cellData.text_halign) : 'center',
|
|
204
|
+
valign: cellData ? TableCanvasRenderer.mapVAlign(cellData.text_valign) : 'middle',
|
|
205
|
+
explicitWidth, explicitHeight, colspan, rowspan,
|
|
206
|
+
skip: false, padX, padY,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── Phase 2: Compute column widths and row heights ──
|
|
212
|
+
const colWidths = new Array(cols).fill(0);
|
|
213
|
+
const rowHeights = new Array(rows).fill(0);
|
|
214
|
+
|
|
215
|
+
for (let r = 0; r < rows; r++) {
|
|
216
|
+
for (let c = 0; c < cols; c++) {
|
|
217
|
+
const info = cellInfos[r][c];
|
|
218
|
+
if (info.skip || info.colspan > 1 || info.rowspan > 1) continue;
|
|
219
|
+
|
|
220
|
+
const textW = TableCanvasRenderer.measureMultiLineWidth(info.lines, info.fontSize, info.fontFamily);
|
|
221
|
+
const numLines = Math.max(info.lines.length, 1);
|
|
222
|
+
|
|
223
|
+
const cellW = info.explicitWidth > 0
|
|
224
|
+
? info.explicitWidth
|
|
225
|
+
: textW + info.padX * 2;
|
|
226
|
+
const cellH = info.explicitHeight > 0
|
|
227
|
+
? info.explicitHeight
|
|
228
|
+
: numLines * info.fontSize * LINE_HEIGHT + info.padY * 2;
|
|
229
|
+
colWidths[c] = Math.max(colWidths[c], cellW);
|
|
230
|
+
rowHeights[r] = Math.max(rowHeights[r], cellH);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
for (let c = 0; c < cols; c++) { if (colWidths[c] === 0) colWidths[c] = 20; }
|
|
235
|
+
for (let r = 0; r < rows; r++) { if (rowHeights[r] === 0) rowHeights[r] = 4; }
|
|
236
|
+
|
|
237
|
+
// Distribute merged cell sizes.
|
|
238
|
+
// Cells with colspan > 1 or rowspan > 1 are skipped in the initial sizing pass.
|
|
239
|
+
// This second pass ensures their text content fits.
|
|
240
|
+
for (let r = 0; r < rows; r++) {
|
|
241
|
+
for (let c = 0; c < cols; c++) {
|
|
242
|
+
const info = cellInfos[r][c];
|
|
243
|
+
if (info.skip) continue;
|
|
244
|
+
|
|
245
|
+
const numLines = Math.max(info.lines.length, 1);
|
|
246
|
+
const neededH = info.explicitHeight > 0
|
|
247
|
+
? info.explicitHeight
|
|
248
|
+
: numLines * info.fontSize * LINE_HEIGHT + info.padY * 2;
|
|
249
|
+
|
|
250
|
+
if (info.colspan > 1) {
|
|
251
|
+
// Expand columns to fit this merged cell's text width
|
|
252
|
+
const spanned = TableCanvasRenderer.sumRange(colWidths, c, info.colspan);
|
|
253
|
+
const textW = TableCanvasRenderer.measureMultiLineWidth(info.lines, info.fontSize, info.fontFamily);
|
|
254
|
+
const neededW = info.explicitWidth > 0
|
|
255
|
+
? info.explicitWidth
|
|
256
|
+
: textW + info.padX * 2;
|
|
257
|
+
if (neededW > spanned) {
|
|
258
|
+
const perCol = (neededW - spanned) / info.colspan;
|
|
259
|
+
for (let i = 0; i < info.colspan; i++) colWidths[c + i] += perCol;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// For colspan-only merges (rowspan=1), also ensure the single row
|
|
263
|
+
// is tall enough for the merged cell's multi-line text.
|
|
264
|
+
if (info.rowspan === 1) {
|
|
265
|
+
rowHeights[r] = Math.max(rowHeights[r], neededH);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (info.rowspan > 1) {
|
|
270
|
+
// Expand rows to fit this merged cell's text height
|
|
271
|
+
const spanned = TableCanvasRenderer.sumRange(rowHeights, r, info.rowspan);
|
|
272
|
+
if (neededH > spanned) {
|
|
273
|
+
const perRow = (neededH - spanned) / info.rowspan;
|
|
274
|
+
for (let i = 0; i < info.rowspan; i++) rowHeights[r + i] += perRow;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Round column widths and row heights to integers to prevent sub-pixel gaps
|
|
281
|
+
for (let c = 0; c < cols; c++) colWidths[c] = Math.round(colWidths[c]);
|
|
282
|
+
for (let r = 0; r < rows; r++) rowHeights[r] = Math.round(rowHeights[r]);
|
|
283
|
+
|
|
284
|
+
// Build cumulative position arrays (no extra spacing for borders —
|
|
285
|
+
// cell border strokes overlap at shared edges, matching TradingView)
|
|
286
|
+
const colX = new Array(cols + 1).fill(0);
|
|
287
|
+
for (let c = 0; c < cols; c++) colX[c + 1] = colX[c] + colWidths[c];
|
|
288
|
+
const rowY = new Array(rows + 1).fill(0);
|
|
289
|
+
for (let r = 0; r < rows; r++) rowY[r + 1] = rowY[r] + rowHeights[r];
|
|
290
|
+
|
|
291
|
+
const frameOffset = hasFrame ? frameWidth : 0;
|
|
292
|
+
const totalWidth = colX[cols] + frameOffset * 2;
|
|
293
|
+
const totalHeight = rowY[rows] + frameOffset * 2;
|
|
294
|
+
|
|
295
|
+
const clampedWidth = Math.min(totalWidth, gridRect.width);
|
|
296
|
+
const clampedHeight = Math.min(totalHeight, gridRect.height);
|
|
297
|
+
|
|
298
|
+
// ── Phase 3: Position the table within the grid (absolute px, rounded) ──
|
|
299
|
+
const pos = TableCanvasRenderer.computePosition(
|
|
300
|
+
tbl.position, gridRect, clampedWidth, clampedHeight,
|
|
301
|
+
);
|
|
302
|
+
const tableX = Math.round(pos.x);
|
|
303
|
+
const tableY = Math.round(pos.y);
|
|
304
|
+
|
|
305
|
+
// ── Phase 4: Build flat graphic elements with absolute positions ──
|
|
306
|
+
const elements: any[] = [];
|
|
307
|
+
const ox = tableX + frameOffset;
|
|
308
|
+
const oy = tableY + frameOffset;
|
|
309
|
+
|
|
310
|
+
// Table background — single rect covering entire table area
|
|
311
|
+
if (tbl.bgcolor) {
|
|
312
|
+
const { color, opacity } = TableCanvasRenderer.parseColor(tbl.bgcolor);
|
|
313
|
+
if (opacity > 0) {
|
|
314
|
+
elements.push({
|
|
315
|
+
type: 'rect',
|
|
316
|
+
shape: { x: tableX, y: tableY, width: clampedWidth, height: clampedHeight },
|
|
317
|
+
style: { fill: color, opacity },
|
|
318
|
+
silent: true,
|
|
319
|
+
z: 0,
|
|
320
|
+
z2: 0,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Frame border (drawn as inset stroke)
|
|
326
|
+
if (hasFrame) {
|
|
327
|
+
const { color: fc } = TableCanvasRenderer.parseColor(tbl.frame_color);
|
|
328
|
+
const half = frameWidth / 2;
|
|
329
|
+
elements.push({
|
|
330
|
+
type: 'rect',
|
|
331
|
+
shape: {
|
|
332
|
+
x: tableX + half,
|
|
333
|
+
y: tableY + half,
|
|
334
|
+
width: clampedWidth - frameWidth,
|
|
335
|
+
height: clampedHeight - frameWidth,
|
|
336
|
+
},
|
|
337
|
+
style: { fill: 'none', stroke: fc, lineWidth: frameWidth },
|
|
338
|
+
silent: true,
|
|
339
|
+
z: 0,
|
|
340
|
+
z2: 1,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Cell backgrounds and borders
|
|
345
|
+
const bdrColor = hasCellBorders ? TableCanvasRenderer.parseColor(tbl.border_color).color : '';
|
|
346
|
+
|
|
347
|
+
for (let r = 0; r < rows; r++) {
|
|
348
|
+
for (let c = 0; c < cols; c++) {
|
|
349
|
+
const info = cellInfos[r][c];
|
|
350
|
+
if (info.skip) continue;
|
|
351
|
+
|
|
352
|
+
const cx = ox + colX[c];
|
|
353
|
+
const cy = oy + rowY[r];
|
|
354
|
+
const cw = TableCanvasRenderer.sumRange(colWidths, c, info.colspan);
|
|
355
|
+
const ch = TableCanvasRenderer.sumRange(rowHeights, r, info.rowspan);
|
|
356
|
+
|
|
357
|
+
// Clip to table bounds
|
|
358
|
+
if (cx - tableX >= clampedWidth || cy - tableY >= clampedHeight) continue;
|
|
359
|
+
const drawW = Math.min(cw, clampedWidth - (cx - tableX));
|
|
360
|
+
const drawH = Math.min(ch, clampedHeight - (cy - tableY));
|
|
361
|
+
|
|
362
|
+
// Cell background
|
|
363
|
+
if (info.bgColor.opacity > 0) {
|
|
364
|
+
elements.push({
|
|
365
|
+
type: 'rect',
|
|
366
|
+
shape: { x: cx, y: cy, width: drawW, height: drawH },
|
|
367
|
+
style: { fill: info.bgColor.color, opacity: info.bgColor.opacity },
|
|
368
|
+
silent: true, z: 0, z2: 2,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Cell border — stroke centered on cell edge (overlaps with neighbors)
|
|
373
|
+
if (hasCellBorders) {
|
|
374
|
+
elements.push({
|
|
375
|
+
type: 'rect',
|
|
376
|
+
shape: { x: cx, y: cy, width: drawW, height: drawH },
|
|
377
|
+
style: { fill: 'none', stroke: bdrColor, lineWidth: borderWidth },
|
|
378
|
+
silent: true, z: 0, z2: 3,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Cell text
|
|
383
|
+
if (info.text) {
|
|
384
|
+
let textX: number, textAlign: 'left' | 'center' | 'right';
|
|
385
|
+
switch (info.halign) {
|
|
386
|
+
case 'left': textX = cx + info.padX; textAlign = 'left'; break;
|
|
387
|
+
case 'right': textX = cx + drawW - info.padX; textAlign = 'right'; break;
|
|
388
|
+
default: textX = cx + drawW / 2; textAlign = 'center'; break;
|
|
389
|
+
}
|
|
390
|
+
let textY: number, textVAlign: 'top' | 'middle' | 'bottom';
|
|
391
|
+
switch (info.valign) {
|
|
392
|
+
case 'top': textY = cy + info.padY; textVAlign = 'top'; break;
|
|
393
|
+
case 'bottom': textY = cy + drawH - info.padY; textVAlign = 'bottom'; break;
|
|
394
|
+
default: textY = cy + drawH / 2; textVAlign = 'middle'; break;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
elements.push({
|
|
398
|
+
type: 'text',
|
|
399
|
+
x: textX,
|
|
400
|
+
y: textY,
|
|
401
|
+
style: {
|
|
402
|
+
text: info.text,
|
|
403
|
+
fill: info.textColor.color,
|
|
404
|
+
opacity: info.textColor.opacity,
|
|
405
|
+
font: `${info.fontSize}px ${info.fontFamily}`,
|
|
406
|
+
textAlign,
|
|
407
|
+
textVerticalAlign: textVAlign,
|
|
408
|
+
lineHeight: Math.round(info.fontSize * LINE_HEIGHT),
|
|
409
|
+
},
|
|
410
|
+
silent: true, z: 0, z2: 4,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return elements;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ── Position Computation ─────────────────────────────────
|
|
420
|
+
|
|
421
|
+
private static computePosition(
|
|
422
|
+
position: string,
|
|
423
|
+
gridRect: { x: number; y: number; width: number; height: number },
|
|
424
|
+
tableWidth: number,
|
|
425
|
+
tableHeight: number,
|
|
426
|
+
): { x: number; y: number } {
|
|
427
|
+
const PAD = 4;
|
|
428
|
+
const gx = gridRect.x;
|
|
429
|
+
const gy = gridRect.y;
|
|
430
|
+
const gw = gridRect.width;
|
|
431
|
+
const gh = gridRect.height;
|
|
432
|
+
|
|
433
|
+
switch (position) {
|
|
434
|
+
case 'top_left': return { x: gx + PAD, y: gy + PAD };
|
|
435
|
+
case 'top_center': return { x: gx + (gw - tableWidth) / 2, y: gy + PAD };
|
|
436
|
+
case 'top_right': return { x: gx + gw - tableWidth - PAD, y: gy + PAD };
|
|
437
|
+
case 'middle_left': return { x: gx + PAD, y: gy + (gh - tableHeight) / 2 };
|
|
438
|
+
case 'middle_center': return { x: gx + (gw - tableWidth) / 2, y: gy + (gh - tableHeight) / 2 };
|
|
439
|
+
case 'middle_right': return { x: gx + gw - tableWidth - PAD, y: gy + (gh - tableHeight) / 2 };
|
|
440
|
+
case 'bottom_left': return { x: gx + PAD, y: gy + gh - tableHeight - PAD };
|
|
441
|
+
case 'bottom_center': return { x: gx + (gw - tableWidth) / 2, y: gy + gh - tableHeight - PAD };
|
|
442
|
+
case 'bottom_right': return { x: gx + gw - tableWidth - PAD, y: gy + gh - tableHeight - PAD };
|
|
443
|
+
default: return { x: gx + gw - tableWidth - PAD, y: gy + PAD };
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ── Utilities ────────────────────────────────────────────
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Measure the max width across all lines of a multi-line text string.
|
|
451
|
+
*/
|
|
452
|
+
private static measureMultiLineWidth(lines: string[], fontSize: number, fontFamily: string): number {
|
|
453
|
+
if (!lines || lines.length === 0) return 0;
|
|
454
|
+
const ratio = fontFamily === 'monospace' ? 0.6 : 0.55;
|
|
455
|
+
let maxW = 0;
|
|
456
|
+
for (const line of lines) {
|
|
457
|
+
maxW = Math.max(maxW, line.length * fontSize * ratio);
|
|
458
|
+
}
|
|
459
|
+
return maxW;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
private static sumRange(arr: number[], start: number, count: number): number {
|
|
463
|
+
let sum = 0;
|
|
464
|
+
for (let i = start; i < start + count && i < arr.length; i++) sum += arr[i];
|
|
465
|
+
return sum;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
@@ -30,10 +30,13 @@ export class TableOverlayRenderer {
|
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
32
|
* Clear all existing table overlays and render new ones.
|
|
33
|
-
* @param
|
|
34
|
-
* representing the actual plot area within the container.
|
|
33
|
+
* @param getGridRect Function that returns the ECharts grid rect for a given pane index.
|
|
35
34
|
*/
|
|
36
|
-
static render(
|
|
35
|
+
static render(
|
|
36
|
+
container: HTMLElement,
|
|
37
|
+
tables: any[],
|
|
38
|
+
getGridRect?: (paneIndex: number) => { x: number; y: number; width: number; height: number } | undefined,
|
|
39
|
+
): void {
|
|
37
40
|
TableOverlayRenderer.clearAll(container);
|
|
38
41
|
|
|
39
42
|
// Pine Script: only the last table at each position is displayed
|
|
@@ -45,7 +48,9 @@ export class TableOverlayRenderer {
|
|
|
45
48
|
}
|
|
46
49
|
|
|
47
50
|
byPosition.forEach((tbl) => {
|
|
48
|
-
const
|
|
51
|
+
const paneIndex = tbl._paneIndex ?? 0;
|
|
52
|
+
const gridRect = getGridRect ? getGridRect(paneIndex) : undefined;
|
|
53
|
+
const el = TableOverlayRenderer.buildTable(tbl, gridRect);
|
|
49
54
|
TableOverlayRenderer.positionTable(el, tbl.position, gridRect);
|
|
50
55
|
container.appendChild(el);
|
|
51
56
|
});
|
|
@@ -57,16 +62,35 @@ export class TableOverlayRenderer {
|
|
|
57
62
|
}
|
|
58
63
|
}
|
|
59
64
|
|
|
60
|
-
private static buildTable(
|
|
65
|
+
private static buildTable(
|
|
66
|
+
tbl: any,
|
|
67
|
+
gridRect?: { x: number; y: number; width: number; height: number },
|
|
68
|
+
): HTMLElement {
|
|
61
69
|
const table = document.createElement('table');
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
70
|
+
const borderWidth = tbl.border_width ?? 0;
|
|
71
|
+
const frameWidth = tbl.frame_width ?? 0;
|
|
72
|
+
// Use collapse when no visible borders — prevents sub-pixel hairlines between cells.
|
|
73
|
+
// Use separate when visible borders are present — so each cell's border is drawn independently.
|
|
74
|
+
const hasVisibleBorders = (borderWidth > 0 && !!tbl.border_color) || (frameWidth > 0 && !!tbl.frame_color);
|
|
75
|
+
if (hasVisibleBorders) {
|
|
76
|
+
table.style.borderCollapse = 'separate';
|
|
77
|
+
table.style.borderSpacing = '0';
|
|
78
|
+
} else {
|
|
79
|
+
table.style.borderCollapse = 'collapse';
|
|
80
|
+
}
|
|
81
|
+
table.style.pointerEvents = 'none';
|
|
65
82
|
table.style.fontSize = '14px';
|
|
66
83
|
table.style.lineHeight = '1.4';
|
|
67
84
|
table.style.fontFamily = 'sans-serif';
|
|
68
85
|
table.style.margin = '4px';
|
|
69
86
|
|
|
87
|
+
// Constrain table to chart area so it doesn't overflow
|
|
88
|
+
if (gridRect) {
|
|
89
|
+
table.style.maxHeight = gridRect.height + 'px';
|
|
90
|
+
table.style.maxWidth = gridRect.width + 'px';
|
|
91
|
+
table.style.overflow = 'hidden';
|
|
92
|
+
}
|
|
93
|
+
|
|
70
94
|
// Table background
|
|
71
95
|
if (tbl.bgcolor) {
|
|
72
96
|
const { color, opacity } = TableOverlayRenderer.safeParseColor(tbl.bgcolor);
|
|
@@ -75,11 +99,13 @@ export class TableOverlayRenderer {
|
|
|
75
99
|
}
|
|
76
100
|
|
|
77
101
|
// Frame (outer border)
|
|
78
|
-
|
|
102
|
+
// Pine Script default frame_color is "no color" (transparent), so only
|
|
103
|
+
// draw frame when an explicit color is provided.
|
|
104
|
+
if (frameWidth > 0 && tbl.frame_color) {
|
|
79
105
|
const { color: fc } = TableOverlayRenderer.safeParseColor(tbl.frame_color);
|
|
80
|
-
table.style.border = `${
|
|
81
|
-
} else
|
|
82
|
-
table.style.border =
|
|
106
|
+
table.style.border = `${frameWidth}px solid ${fc}`;
|
|
107
|
+
} else {
|
|
108
|
+
table.style.border = 'none';
|
|
83
109
|
}
|
|
84
110
|
|
|
85
111
|
// Build merge lookup: for each cell, determine colspan/rowspan
|
|
@@ -103,6 +129,14 @@ export class TableOverlayRenderer {
|
|
|
103
129
|
}
|
|
104
130
|
}
|
|
105
131
|
|
|
132
|
+
// Cell border settings
|
|
133
|
+
// Pine Script default border_color is "no color" (transparent), so only
|
|
134
|
+
// draw cell borders when an explicit color is provided.
|
|
135
|
+
const hasCellBorders = borderWidth > 0 && !!tbl.border_color;
|
|
136
|
+
const borderColorStr = hasCellBorders
|
|
137
|
+
? TableOverlayRenderer.safeParseColor(tbl.border_color).color
|
|
138
|
+
: '';
|
|
139
|
+
|
|
106
140
|
// Build rows
|
|
107
141
|
const rows = tbl.rows || 0;
|
|
108
142
|
const cols = tbl.columns || 0;
|
|
@@ -126,11 +160,10 @@ export class TableOverlayRenderer {
|
|
|
126
160
|
}
|
|
127
161
|
|
|
128
162
|
// Cell borders
|
|
129
|
-
if (
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
td.style.border = `${tbl.border_width}px solid ${bc}`;
|
|
163
|
+
if (hasCellBorders) {
|
|
164
|
+
td.style.border = `${borderWidth}px solid ${borderColorStr}`;
|
|
165
|
+
} else {
|
|
166
|
+
td.style.border = 'none';
|
|
134
167
|
}
|
|
135
168
|
|
|
136
169
|
// Get cell data
|
|
@@ -139,8 +172,10 @@ export class TableOverlayRenderer {
|
|
|
139
172
|
// Cell text
|
|
140
173
|
td.textContent = cellData.text || '';
|
|
141
174
|
|
|
142
|
-
// Cell background
|
|
143
|
-
|
|
175
|
+
// Cell background — only apply if an explicit color string is set.
|
|
176
|
+
// Empty string or na (NaN) means "no color" → transparent,
|
|
177
|
+
// so the table's own bgcolor shows through.
|
|
178
|
+
if (cellData.bgcolor && typeof cellData.bgcolor === 'string' && cellData.bgcolor.length > 0) {
|
|
144
179
|
const { color: bg, opacity: bgOp } = TableOverlayRenderer.safeParseColor(cellData.bgcolor);
|
|
145
180
|
td.style.backgroundColor = bg;
|
|
146
181
|
if (bgOp < 1) {
|
|
@@ -167,12 +202,23 @@ export class TableOverlayRenderer {
|
|
|
167
202
|
td.style.fontFamily = 'monospace';
|
|
168
203
|
}
|
|
169
204
|
|
|
170
|
-
// Width/height
|
|
205
|
+
// Width/height: Pine Script defines these as % of chart visual space (0-100).
|
|
206
|
+
// Convert to pixels using gridRect so the table scales with chart size.
|
|
171
207
|
if (cellData.width > 0) {
|
|
172
|
-
|
|
208
|
+
if (gridRect) {
|
|
209
|
+
const px = Math.max(1, cellData.width * gridRect.width / 100);
|
|
210
|
+
td.style.width = px + 'px';
|
|
211
|
+
} else {
|
|
212
|
+
td.style.width = cellData.width + '%';
|
|
213
|
+
}
|
|
173
214
|
}
|
|
174
215
|
if (cellData.height > 0) {
|
|
175
|
-
|
|
216
|
+
if (gridRect) {
|
|
217
|
+
const px = Math.max(1, cellData.height * gridRect.height / 100);
|
|
218
|
+
td.style.height = px + 'px';
|
|
219
|
+
} else {
|
|
220
|
+
td.style.height = cellData.height + '%';
|
|
221
|
+
}
|
|
176
222
|
}
|
|
177
223
|
|
|
178
224
|
// Tooltip
|
|
@@ -181,8 +227,14 @@ export class TableOverlayRenderer {
|
|
|
181
227
|
}
|
|
182
228
|
}
|
|
183
229
|
|
|
184
|
-
//
|
|
185
|
-
|
|
230
|
+
// Padding: use minimal padding for cells with tiny explicit heights
|
|
231
|
+
// (e.g., bar-chart rows with height=0.1 in PTAG indicators)
|
|
232
|
+
const cellHeight = cellData?.height ?? 0;
|
|
233
|
+
if (cellHeight > 0 && gridRect && cellHeight * gridRect.height / 100 < 4) {
|
|
234
|
+
td.style.padding = '0';
|
|
235
|
+
} else {
|
|
236
|
+
td.style.padding = '4px 6px';
|
|
237
|
+
}
|
|
186
238
|
td.style.whiteSpace = 'nowrap';
|
|
187
239
|
|
|
188
240
|
tr.appendChild(td);
|