@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.
@@ -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(tbl: any): HTMLElement {
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
- if (cellData.bgcolor) {
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 (% of chart area, 0 = auto)
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
- td.style.width = cellData.width + '%';
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
- td.style.height = cellData.height + '%';
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
- // Default padding
208
- td.style.padding = '4px 6px';
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);