@qfo/qfchart 0.7.3 → 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 +49 -2
- package/dist/qfchart.min.browser.js +18 -16
- package/dist/qfchart.min.es.js +18 -16
- package/package.json +1 -1
- package/src/QFChart.ts +372 -59
- package/src/components/GraphicBuilder.ts +284 -263
- package/src/components/LayoutManager.ts +33 -22
- package/src/components/SeriesBuilder.ts +104 -0
- package/src/components/TableCanvasRenderer.ts +467 -0
- package/src/components/TableOverlayRenderer.ts +38 -9
- package/src/components/TooltipFormatter.ts +97 -97
- package/src/components/renderers/BackgroundRenderer.ts +59 -47
- package/src/components/renderers/BoxRenderer.ts +113 -17
- package/src/components/renderers/FillRenderer.ts +118 -3
- package/src/components/renderers/LabelRenderer.ts +35 -9
- package/src/components/renderers/OHLCBarRenderer.ts +171 -161
- package/src/components/renderers/PolylineRenderer.ts +26 -19
- 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
|
+
}
|
|
@@ -50,7 +50,7 @@ export class TableOverlayRenderer {
|
|
|
50
50
|
byPosition.forEach((tbl) => {
|
|
51
51
|
const paneIndex = tbl._paneIndex ?? 0;
|
|
52
52
|
const gridRect = getGridRect ? getGridRect(paneIndex) : undefined;
|
|
53
|
-
const el = TableOverlayRenderer.buildTable(tbl);
|
|
53
|
+
const el = TableOverlayRenderer.buildTable(tbl, gridRect);
|
|
54
54
|
TableOverlayRenderer.positionTable(el, tbl.position, gridRect);
|
|
55
55
|
container.appendChild(el);
|
|
56
56
|
});
|
|
@@ -62,7 +62,10 @@ export class TableOverlayRenderer {
|
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
private static buildTable(
|
|
65
|
+
private static buildTable(
|
|
66
|
+
tbl: any,
|
|
67
|
+
gridRect?: { x: number; y: number; width: number; height: number },
|
|
68
|
+
): HTMLElement {
|
|
66
69
|
const table = document.createElement('table');
|
|
67
70
|
const borderWidth = tbl.border_width ?? 0;
|
|
68
71
|
const frameWidth = tbl.frame_width ?? 0;
|
|
@@ -81,6 +84,13 @@ export class TableOverlayRenderer {
|
|
|
81
84
|
table.style.fontFamily = 'sans-serif';
|
|
82
85
|
table.style.margin = '4px';
|
|
83
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
|
+
|
|
84
94
|
// Table background
|
|
85
95
|
if (tbl.bgcolor) {
|
|
86
96
|
const { color, opacity } = TableOverlayRenderer.safeParseColor(tbl.bgcolor);
|
|
@@ -162,8 +172,10 @@ export class TableOverlayRenderer {
|
|
|
162
172
|
// Cell text
|
|
163
173
|
td.textContent = cellData.text || '';
|
|
164
174
|
|
|
165
|
-
// Cell background
|
|
166
|
-
|
|
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) {
|
|
167
179
|
const { color: bg, opacity: bgOp } = TableOverlayRenderer.safeParseColor(cellData.bgcolor);
|
|
168
180
|
td.style.backgroundColor = bg;
|
|
169
181
|
if (bgOp < 1) {
|
|
@@ -190,12 +202,23 @@ export class TableOverlayRenderer {
|
|
|
190
202
|
td.style.fontFamily = 'monospace';
|
|
191
203
|
}
|
|
192
204
|
|
|
193
|
-
// 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.
|
|
194
207
|
if (cellData.width > 0) {
|
|
195
|
-
|
|
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
|
+
}
|
|
196
214
|
}
|
|
197
215
|
if (cellData.height > 0) {
|
|
198
|
-
|
|
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
|
+
}
|
|
199
222
|
}
|
|
200
223
|
|
|
201
224
|
// Tooltip
|
|
@@ -204,8 +227,14 @@ export class TableOverlayRenderer {
|
|
|
204
227
|
}
|
|
205
228
|
}
|
|
206
229
|
|
|
207
|
-
//
|
|
208
|
-
|
|
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
|
+
}
|
|
209
238
|
td.style.whiteSpace = 'nowrap';
|
|
210
239
|
|
|
211
240
|
tr.appendChild(td);
|