@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.
@@ -1,97 +1,97 @@
1
- import { QFChartOptions } from "../types";
2
-
3
- export class TooltipFormatter {
4
- public static format(params: any[], options: QFChartOptions): string {
5
- if (!params || params.length === 0) return "";
6
-
7
- const marketName = options.title || "Market";
8
- const upColor = options.upColor || "#00da3c";
9
- const downColor = options.downColor || "#ec0000";
10
- const fontFamily = options.fontFamily || "sans-serif";
11
-
12
- // 1. Header: Date/Time (from the first param)
13
- const date = params[0].axisValue;
14
- let html = `<div style="font-weight: bold; margin-bottom: 5px; color: #cbd5e1; font-family: ${fontFamily};">${date}</div>`;
15
-
16
- // 2. Separate Market Data (Candlestick) from Indicators
17
- const marketSeries = params.find(
18
- (p: any) => p.seriesType === "candlestick"
19
- );
20
- const indicatorParams = params.filter(
21
- (p: any) => p.seriesType !== "candlestick"
22
- );
23
-
24
- // 3. Market Data Section
25
- if (marketSeries) {
26
- const [_, open, close, low, high] = marketSeries.value;
27
- const color = close >= open ? upColor : downColor;
28
-
29
- html += `
30
- <div style="margin-bottom: 8px; font-family: ${fontFamily};">
31
- <div style="display:flex; justify-content:space-between; color:${color}; font-weight:bold;">
32
- <span>${marketName}</span>
33
- </div>
34
- <div style="display: grid; grid-template-columns: auto auto; gap: 2px 15px; font-size: 0.9em; color: #cbd5e1;">
35
- <span>Open:</span> <span style="text-align: right; color: ${
36
- close >= open ? upColor : downColor
37
- }">${open}</span>
38
- <span>High:</span> <span style="text-align: right; color: ${upColor}">${high}</span>
39
- <span>Low:</span> <span style="text-align: right; color: ${downColor}">${low}</span>
40
- <span>Close:</span> <span style="text-align: right; color: ${
41
- close >= open ? upColor : downColor
42
- }">${close}</span>
43
- </div>
44
- </div>
45
- `;
46
- }
47
-
48
- // 4. Indicators Section
49
- if (indicatorParams.length > 0) {
50
- html += `<div style="border-top: 1px solid #334155; margin: 5px 0; padding-top: 5px;"></div>`;
51
-
52
- // Group by Indicator ID (extracted from seriesName "ID::PlotName")
53
- const indicators: { [key: string]: any[] } = {};
54
-
55
- indicatorParams.forEach((p: any) => {
56
- const parts = p.seriesName.split("::");
57
- const indId = parts.length > 1 ? parts[0] : "Unknown";
58
- const plotName = parts.length > 1 ? parts[1] : p.seriesName;
59
-
60
- if (!indicators[indId]) indicators[indId] = [];
61
- indicators[indId].push({ ...p, displayName: plotName });
62
- });
63
-
64
- // Render groups
65
- Object.keys(indicators).forEach((indId) => {
66
- html += `
67
- <div style="margin-top: 8px; font-family: ${fontFamily};">
68
- <div style="font-weight:bold; color: #fff; margin-bottom: 2px;">${indId}</div>
69
- `;
70
-
71
- indicators[indId].forEach((p) => {
72
- let val = p.value;
73
- if (Array.isArray(val)) {
74
- val = val[1]; // Assuming [index, value]
75
- }
76
-
77
- if (val === null || val === undefined) return;
78
-
79
- const valStr =
80
- typeof val === "number"
81
- ? val.toLocaleString(undefined, { maximumFractionDigits: 4 })
82
- : val;
83
-
84
- html += `
85
- <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2px; padding-left: 8px;">
86
- <div>${p.marker} <span style="color: #cbd5e1;">${p.displayName}</span></div>
87
- <div style="font-size: 10px; color: #fff;padding-left:10px;">${valStr}</div>
88
- </div>`;
89
- });
90
-
91
- html += `</div>`;
92
- });
93
- }
94
-
95
- return html;
96
- }
97
- }
1
+ import { QFChartOptions } from "../types";
2
+
3
+ export class TooltipFormatter {
4
+ public static format(params: any[], options: QFChartOptions): string {
5
+ if (!params || params.length === 0) return "";
6
+
7
+ const marketName = options.title || "Market";
8
+ const upColor = options.upColor || "#00da3c";
9
+ const downColor = options.downColor || "#ec0000";
10
+ const fontFamily = options.fontFamily || "sans-serif";
11
+
12
+ // 1. Header: Date/Time (from the first param)
13
+ const date = params[0].axisValue;
14
+ let html = `<div style="font-weight: bold; margin-bottom: 5px; color: #cbd5e1; font-family: ${fontFamily};">${date}</div>`;
15
+
16
+ // 2. Separate Market Data (Candlestick) from Indicators
17
+ const marketSeries = params.find(
18
+ (p: any) => p.seriesType === "candlestick"
19
+ );
20
+ const indicatorParams = params.filter(
21
+ (p: any) => p.seriesType !== "candlestick"
22
+ );
23
+
24
+ // 3. Market Data Section
25
+ if (marketSeries) {
26
+ const [_, open, close, low, high] = marketSeries.value;
27
+ const color = close >= open ? upColor : downColor;
28
+
29
+ html += `
30
+ <div style="margin-bottom: 8px; font-family: ${fontFamily};">
31
+ <div style="display:flex; justify-content:space-between; color:${color}; font-weight:bold;">
32
+ <span>${marketName}</span>
33
+ </div>
34
+ <div style="display: grid; grid-template-columns: auto auto; gap: 2px 15px; font-size: 0.9em; color: #cbd5e1;">
35
+ <span>Open:</span> <span style="text-align: right; color: ${
36
+ close >= open ? upColor : downColor
37
+ }">${open}</span>
38
+ <span>High:</span> <span style="text-align: right; color: ${upColor}">${high}</span>
39
+ <span>Low:</span> <span style="text-align: right; color: ${downColor}">${low}</span>
40
+ <span>Close:</span> <span style="text-align: right; color: ${
41
+ close >= open ? upColor : downColor
42
+ }">${close}</span>
43
+ </div>
44
+ </div>
45
+ `;
46
+ }
47
+
48
+ // 4. Indicators Section
49
+ if (indicatorParams.length > 0) {
50
+ html += `<div style="border-top: 1px solid #334155; margin: 5px 0; padding-top: 5px;"></div>`;
51
+
52
+ // Group by Indicator ID (extracted from seriesName "ID::PlotName")
53
+ const indicators: { [key: string]: any[] } = {};
54
+
55
+ indicatorParams.forEach((p: any) => {
56
+ const parts = p.seriesName.split("::");
57
+ const indId = parts.length > 1 ? parts[0] : "Unknown";
58
+ const plotName = parts.length > 1 ? parts[1] : p.seriesName;
59
+
60
+ if (!indicators[indId]) indicators[indId] = [];
61
+ indicators[indId].push({ ...p, displayName: plotName });
62
+ });
63
+
64
+ // Render groups
65
+ Object.keys(indicators).forEach((indId) => {
66
+ html += `
67
+ <div style="margin-top: 8px; font-family: ${fontFamily};">
68
+ <div style="font-weight:bold; color: #fff; margin-bottom: 2px;">${indId}</div>
69
+ `;
70
+
71
+ indicators[indId].forEach((p) => {
72
+ let val = p.value;
73
+ if (Array.isArray(val)) {
74
+ val = val[1]; // Assuming [index, value]
75
+ }
76
+
77
+ if (val === null || val === undefined) return;
78
+
79
+ const valStr =
80
+ typeof val === "number"
81
+ ? val.toLocaleString(undefined, { maximumFractionDigits: 4 })
82
+ : val;
83
+
84
+ html += `
85
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2px; padding-left: 8px;">
86
+ <div>${p.marker} <span style="color: #cbd5e1;">${p.displayName}</span></div>
87
+ <div style="font-size: 10px; color: #fff;padding-left:10px;">${valStr}</div>
88
+ </div>`;
89
+ });
90
+
91
+ html += `</div>`;
92
+ });
93
+ }
94
+
95
+ return html;
96
+ }
97
+ }
@@ -1,47 +1,59 @@
1
- import { SeriesRenderer, RenderContext } from './SeriesRenderer';
2
-
3
- export class BackgroundRenderer implements SeriesRenderer {
4
- render(context: RenderContext): any {
5
- const { seriesName, xAxisIndex, yAxisIndex, dataArray, colorArray } = context;
6
-
7
- return {
8
- name: seriesName,
9
- type: 'custom',
10
- xAxisIndex: xAxisIndex,
11
- yAxisIndex: yAxisIndex,
12
- z: -10,
13
- renderItem: (params: any, api: any) => {
14
- const xVal = api.value(0);
15
- if (isNaN(xVal)) return;
16
-
17
- const start = api.coord([xVal, 0.5]); // Use 0.5 as a fixed Y-value within [0,1] range
18
- const size = api.size([1, 0]);
19
- const width = size[0];
20
- const sys = params.coordSys;
21
- const x = start[0] - width / 2;
22
- const barColor = colorArray[params.dataIndex];
23
- const val = api.value(1);
24
-
25
- if (!barColor || val === null || val === undefined || isNaN(val)) return;
26
-
27
- return {
28
- type: 'rect',
29
- shape: {
30
- x: x,
31
- y: sys.y,
32
- width: width,
33
- height: sys.height,
34
- },
35
- style: {
36
- fill: barColor,
37
- opacity: 0.3,
38
- },
39
- silent: true,
40
- };
41
- },
42
- // Normalize data values to 0.5 (middle of [0,1] range) to prevent Y-axis scaling issues
43
- // The actual value is only used to check if the background should render (non-null/non-NaN)
44
- data: dataArray.map((val, i) => [i, val !== null && val !== undefined && !isNaN(val) ? 0.5 : null]),
45
- };
46
- }
47
- }
1
+ import { SeriesRenderer, RenderContext } from './SeriesRenderer';
2
+ import { ColorUtils } from '../../utils/ColorUtils';
3
+
4
+ export class BackgroundRenderer implements SeriesRenderer {
5
+ render(context: RenderContext): any {
6
+ const { seriesName, xAxisIndex, yAxisIndex, dataArray, colorArray } = context;
7
+
8
+ // Pre-parse colors to extract embedded alpha (e.g. #RRGGBBAA → rgb + opacity)
9
+ // This avoids the double-opacity problem where ECharts multiplies a hardcoded
10
+ // opacity with the alpha already embedded in the color string.
11
+ const parsedColors: { color: string; opacity: number }[] = [];
12
+ for (let i = 0; i < colorArray.length; i++) {
13
+ parsedColors[i] = colorArray[i] ? ColorUtils.parseColor(colorArray[i]) : { color: '', opacity: 0 };
14
+ }
15
+
16
+ return {
17
+ name: seriesName,
18
+ type: 'custom',
19
+ xAxisIndex: xAxisIndex,
20
+ yAxisIndex: yAxisIndex,
21
+ z: -10,
22
+ renderItem: (params: any, api: any) => {
23
+ const xVal = api.value(0);
24
+ if (isNaN(xVal)) return;
25
+
26
+ const start = api.coord([xVal, 0.5]); // Use 0.5 as a fixed Y-value within [0,1] range
27
+ const size = api.size([1, 0]);
28
+ const width = size[0];
29
+ const sys = params.coordSys;
30
+ const x = start[0] - width / 2;
31
+ const barColor = colorArray[params.dataIndex];
32
+ const val = api.value(1);
33
+
34
+ if (!barColor || val === null || val === undefined || isNaN(val)) return;
35
+
36
+ const parsed = parsedColors[params.dataIndex];
37
+ if (!parsed || parsed.opacity <= 0) return; // Skip fully transparent
38
+
39
+ return {
40
+ type: 'rect',
41
+ shape: {
42
+ x: x,
43
+ y: sys.y,
44
+ width: width,
45
+ height: sys.height,
46
+ },
47
+ style: {
48
+ fill: parsed.color,
49
+ opacity: parsed.opacity,
50
+ },
51
+ silent: true,
52
+ };
53
+ },
54
+ // Normalize data values to 0.5 (middle of [0,1] range) to prevent Y-axis scaling issues
55
+ // The actual value is only used to check if the background should render (non-null/non-NaN)
56
+ data: dataArray.map((val, i) => [i, val !== null && val !== undefined && !isNaN(val) ? 0.5 : null]),
57
+ };
58
+ }
59
+ }
@@ -19,6 +19,38 @@ function normalizeColor(color: string | undefined): string | undefined {
19
19
  return color;
20
20
  }
21
21
 
22
+ /**
23
+ * Parse a CSS color string into { r, g, b } (0-255 each).
24
+ * Supports #rgb, #rrggbb, #rrggbbaa, rgb(), rgba().
25
+ */
26
+ function parseRGB(color: string | null | undefined): { r: number; g: number; b: number } | null {
27
+ if (!color || typeof color !== 'string') return null;
28
+ if (color.startsWith('#')) {
29
+ const hex = color.slice(1);
30
+ if (hex.length >= 6) {
31
+ const r = parseInt(hex.slice(0, 2), 16);
32
+ const g = parseInt(hex.slice(2, 4), 16);
33
+ const b = parseInt(hex.slice(4, 6), 16);
34
+ if (!isNaN(r) && !isNaN(g) && !isNaN(b)) return { r, g, b };
35
+ }
36
+ if (hex.length === 3) {
37
+ const r = parseInt(hex[0] + hex[0], 16);
38
+ const g = parseInt(hex[1] + hex[1], 16);
39
+ const b = parseInt(hex[2] + hex[2], 16);
40
+ if (!isNaN(r) && !isNaN(g) && !isNaN(b)) return { r, g, b };
41
+ }
42
+ return null;
43
+ }
44
+ const m = color.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
45
+ if (m) return { r: +m[1], g: +m[2], b: +m[3] };
46
+ return null;
47
+ }
48
+
49
+ /** Relative luminance (0 = black, 1 = white). */
50
+ function luminance(r: number, g: number, b: number): number {
51
+ return (0.299 * r + 0.587 * g + 0.114 * b) / 255;
52
+ }
53
+
22
54
  /**
23
55
  * Renderer for Pine Script box.* drawing objects.
24
56
  * Each box is defined by two corners (left,top) → (right,bottom)
@@ -96,14 +128,21 @@ export class BoxRenderer implements SeriesRenderer {
96
128
  }
97
129
 
98
130
  // Background fill rect
99
- const bgColor = normalizeColor(bx.bgcolor) || '#2962ff';
100
- children.push({
101
- type: 'rect',
102
- shape: { x, y, width: w, height: h },
103
- style: { fill: bgColor, stroke: 'none' },
104
- });
105
-
106
- // Border rect (on top of fill)
131
+ // bgcolor = na means no fill (na resolves to NaN or undefined)
132
+ const rawBgColor = bx.bgcolor;
133
+ const isNaBgColor = rawBgColor === null || rawBgColor === undefined ||
134
+ (typeof rawBgColor === 'number' && isNaN(rawBgColor)) ||
135
+ rawBgColor === 'na' || rawBgColor === 'NaN' || rawBgColor === '';
136
+ const bgColor = isNaBgColor ? null : (normalizeColor(rawBgColor) || '#2962ff');
137
+ if (bgColor) {
138
+ children.push({
139
+ type: 'rect',
140
+ shape: { x, y, width: w, height: h },
141
+ style: { fill: bgColor, stroke: 'none' },
142
+ });
143
+ }
144
+
145
+ // Explicit border rect (on top of fill)
107
146
  // border_color = na means no border (na resolves to NaN or undefined)
108
147
  const rawBorderColor = bx.border_color;
109
148
  const isNaBorder = rawBorderColor === null || rawBorderColor === undefined ||
@@ -128,16 +167,38 @@ export class BoxRenderer implements SeriesRenderer {
128
167
  if (bx.text) {
129
168
  const textX = this.getTextX(x, w, bx.text_halign);
130
169
  const textY = this.getTextY(y, h, bx.text_valign);
170
+
171
+ // Auto-contrast: TradingView renders box text as bold white on dark
172
+ // backgrounds. When text_color is the default black, compute luminance
173
+ // of bgcolor and use white text if the background is dark.
174
+ let textFill = normalizeColor(bx.text_color) || '#000000';
175
+ const isDefaultTextColor = !bx.text_color || bx.text_color === '#000000' ||
176
+ bx.text_color === 'black' || bx.text_color === 'color.black';
177
+ if (isDefaultTextColor && bgColor) {
178
+ const rgb = parseRGB(bgColor);
179
+ if (rgb && luminance(rgb.r, rgb.g, rgb.b) < 0.5) {
180
+ textFill = '#FFFFFF';
181
+ }
182
+ }
183
+
184
+ // TradingView renders box text bold by default (format_none → bold)
185
+ const isBold = !bx.text_formatting || bx.text_formatting === 'format_none' ||
186
+ bx.text_formatting === 'format_bold';
187
+
188
+ // Font size: for 'auto'/'size.auto', scale to fit within the box.
189
+ // For named sizes (tiny, small, etc.), use fixed values.
190
+ const fontSize = this.computeFontSize(bx.text_size, bx.text, Math.abs(w), Math.abs(h), isBold);
191
+
131
192
  children.push({
132
193
  type: 'text',
133
194
  style: {
134
195
  x: textX,
135
196
  y: textY,
136
197
  text: bx.text,
137
- fill: normalizeColor(bx.text_color) || '#000000',
138
- fontSize: this.getSizePixels(bx.text_size),
198
+ fill: textFill,
199
+ fontSize,
139
200
  fontFamily: bx.text_font_family === 'monospace' ? 'monospace' : 'sans-serif',
140
- fontWeight: (bx.text_formatting === 'format_bold') ? 'bold' : 'normal',
201
+ fontWeight: isBold ? 'bold' : 'normal',
141
202
  fontStyle: (bx.text_formatting === 'format_italic') ? 'italic' : 'normal',
142
203
  textAlign: this.mapHAlign(bx.text_halign),
143
204
  textVerticalAlign: this.mapVAlign(bx.text_valign),
@@ -170,12 +231,17 @@ export class BoxRenderer implements SeriesRenderer {
170
231
  }
171
232
  }
172
233
 
173
- private getSizePixels(size: string | number): number {
234
+ /**
235
+ * Compute font size for box text.
236
+ * For 'auto'/'size.auto' (the default), dynamically scale text to fit within
237
+ * the box dimensions with a small gap — matching TradingView behavior.
238
+ * For explicit named sizes, return fixed pixel values.
239
+ */
240
+ private computeFontSize(size: string | number, text: string, boxW: number, boxH: number, bold: boolean): number {
174
241
  if (typeof size === 'number' && size > 0) return size;
242
+
243
+ // Fixed named sizes
175
244
  switch (size) {
176
- case 'auto':
177
- case 'size.auto':
178
- return 12;
179
245
  case 'tiny':
180
246
  case 'size.tiny':
181
247
  return 8;
@@ -191,9 +257,39 @@ export class BoxRenderer implements SeriesRenderer {
191
257
  case 'huge':
192
258
  case 'size.huge':
193
259
  return 36;
194
- default:
195
- return 12;
196
260
  }
261
+
262
+ // 'auto' / 'size.auto' / default → scale to fit box
263
+ if (!text || boxW <= 0 || boxH <= 0) return 12;
264
+
265
+ const padding = 6; // px gap on each side
266
+ const availW = boxW - padding * 2;
267
+ const availH = boxH - padding * 2;
268
+ if (availW <= 0 || availH <= 0) return 6;
269
+
270
+ const lines = text.split('\n');
271
+ const numLines = lines.length;
272
+
273
+ // Find the longest line by character count
274
+ let maxChars = 1;
275
+ for (const line of lines) {
276
+ if (line.length > maxChars) maxChars = line.length;
277
+ }
278
+
279
+ // Average character width ratio (font-size relative).
280
+ // Bold sans-serif is ~0.62; regular is ~0.55.
281
+ const charWidthRatio = bold ? 0.62 : 0.55;
282
+
283
+ // Max font size constrained by width: availW = maxChars * fontSize * ratio
284
+ const maxByWidth = availW / (maxChars * charWidthRatio);
285
+
286
+ // Max font size constrained by height: availH = numLines * fontSize * lineHeight
287
+ const lineHeight = 1.3;
288
+ const maxByHeight = availH / (numLines * lineHeight);
289
+
290
+ // Use the smaller of the two, clamped to a reasonable range
291
+ const computed = Math.min(maxByWidth, maxByHeight);
292
+ return Math.max(6, Math.min(computed, 48));
197
293
  }
198
294
 
199
295
  private mapHAlign(align: string): string {
@@ -1,6 +1,15 @@
1
1
  import { SeriesRenderer, RenderContext } from './SeriesRenderer';
2
2
  import { ColorUtils } from '../../utils/ColorUtils';
3
3
 
4
+ /**
5
+ * Configuration for a single fill band within a batched render.
6
+ */
7
+ export interface BatchedFillEntry {
8
+ plot1Data: (number | null)[];
9
+ plot2Data: (number | null)[];
10
+ barColors: { color: string; opacity: number }[];
11
+ }
12
+
4
13
  export class FillRenderer implements SeriesRenderer {
5
14
  render(context: RenderContext): any {
6
15
  const { seriesName, xAxisIndex, yAxisIndex, plotOptions, plotDataArrays, indicatorId, plotName, optionsArray } = context;
@@ -34,8 +43,25 @@ export class FillRenderer implements SeriesRenderer {
34
43
  );
35
44
  }
36
45
 
37
- // --- Simple (solid color) fill ---
38
- const { color: fillColor, opacity: fillOpacity } = ColorUtils.parseColor(plotOptions.color || 'rgba(128, 128, 128, 0.2)');
46
+ // --- Simple fill (supports per-bar color when color is a series) ---
47
+ const { color: defaultFillColor, opacity: defaultFillOpacity } = ColorUtils.parseColor(plotOptions.color || 'rgba(128, 128, 128, 0.2)');
48
+
49
+ // Check if we have per-bar color data in optionsArray
50
+ const hasPerBarColor = optionsArray?.some((o: any) => o && o.color !== undefined);
51
+
52
+ // Pre-parse per-bar colors for efficiency
53
+ let barColors: { color: string; opacity: number }[] | null = null;
54
+ if (hasPerBarColor) {
55
+ barColors = [];
56
+ for (let i = 0; i < totalDataLength; i++) {
57
+ const opts = optionsArray?.[i];
58
+ if (opts && opts.color !== undefined) {
59
+ barColors[i] = ColorUtils.parseColor(opts.color);
60
+ } else {
61
+ barColors[i] = { color: defaultFillColor, opacity: defaultFillOpacity };
62
+ }
63
+ }
64
+ }
39
65
 
40
66
  // Create fill data with previous values for smooth polygon rendering
41
67
  const fillDataWithPrev: any[] = [];
@@ -54,6 +80,9 @@ export class FillRenderer implements SeriesRenderer {
54
80
  xAxisIndex: xAxisIndex,
55
81
  yAxisIndex: yAxisIndex,
56
82
  z: 1,
83
+ clip: true,
84
+ encode: { x: 0 },
85
+ animation: false,
57
86
  renderItem: (params: any, api: any) => {
58
87
  const index = params.dataIndex;
59
88
  if (index === 0) return null;
@@ -70,6 +99,12 @@ export class FillRenderer implements SeriesRenderer {
70
99
  return null;
71
100
  }
72
101
 
102
+ const fc = barColors ? barColors[index] : null;
103
+
104
+ // Skip fully transparent fills
105
+ const fillOpacity = fc ? fc.opacity : defaultFillOpacity;
106
+ if (fillOpacity < 0.01) return null;
107
+
73
108
  const p1Prev = api.coord([index - 1, prevY1]);
74
109
  const p1Curr = api.coord([index, y1]);
75
110
  const p2Curr = api.coord([index, y2]);
@@ -81,13 +116,86 @@ export class FillRenderer implements SeriesRenderer {
81
116
  points: [p1Prev, p1Curr, p2Curr, p2Prev],
82
117
  },
83
118
  style: {
84
- fill: fillColor,
119
+ fill: fc ? fc.color : defaultFillColor,
85
120
  opacity: fillOpacity,
86
121
  },
87
122
  silent: true,
88
123
  };
89
124
  },
90
125
  data: fillDataWithPrev,
126
+ silent: true,
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Batch-render multiple fill bands as a single ECharts custom series.
132
+ * Instead of N separate series (one per fill), this creates ONE series
133
+ * where each renderItem call draws all fill bands as a group of children.
134
+ *
135
+ * Performance: reduces series count from N to 1, eliminates per-series
136
+ * ECharts overhead, and enables viewport culling via clip + encode.
137
+ */
138
+ renderBatched(
139
+ seriesName: string,
140
+ xAxisIndex: number,
141
+ yAxisIndex: number,
142
+ totalDataLength: number,
143
+ fills: BatchedFillEntry[]
144
+ ): any {
145
+ // Simple index-only data for ECharts — encode: {x:0} enables dataZoom filtering
146
+ const data = Array.from({ length: totalDataLength }, (_, i) => [i]);
147
+
148
+ return {
149
+ name: seriesName,
150
+ type: 'custom',
151
+ xAxisIndex,
152
+ yAxisIndex,
153
+ z: 1,
154
+ clip: true,
155
+ encode: { x: 0 },
156
+ animation: false,
157
+ renderItem: (params: any, api: any) => {
158
+ const index = params.dataIndex;
159
+ if (index === 0) return null;
160
+
161
+ const children: any[] = [];
162
+
163
+ for (let f = 0; f < fills.length; f++) {
164
+ const fill = fills[f];
165
+ const y1 = fill.plot1Data[index];
166
+ const y2 = fill.plot2Data[index];
167
+ const prevY1 = fill.plot1Data[index - 1];
168
+ const prevY2 = fill.plot2Data[index - 1];
169
+
170
+ if (
171
+ y1 == null || y2 == null || prevY1 == null || prevY2 == null ||
172
+ isNaN(y1 as number) || isNaN(y2 as number) ||
173
+ isNaN(prevY1 as number) || isNaN(prevY2 as number)
174
+ ) {
175
+ continue;
176
+ }
177
+
178
+ // Skip fully transparent fills
179
+ const fc = fill.barColors[index];
180
+ if (!fc || fc.opacity < 0.01) continue;
181
+
182
+ const p1Prev = api.coord([index - 1, prevY1]);
183
+ const p1Curr = api.coord([index, y1]);
184
+ const p2Curr = api.coord([index, y2]);
185
+ const p2Prev = api.coord([index - 1, prevY2]);
186
+
187
+ children.push({
188
+ type: 'polygon',
189
+ shape: { points: [p1Prev, p1Curr, p2Curr, p2Prev] },
190
+ style: { fill: fc.color, opacity: fc.opacity },
191
+ silent: true,
192
+ });
193
+ }
194
+
195
+ return children.length > 0 ? { type: 'group', children, silent: true } : null;
196
+ },
197
+ data,
198
+ silent: true,
91
199
  };
92
200
  }
93
201
 
@@ -148,6 +256,9 @@ export class FillRenderer implements SeriesRenderer {
148
256
  xAxisIndex: xAxisIndex,
149
257
  yAxisIndex: yAxisIndex,
150
258
  z: 1,
259
+ clip: true,
260
+ encode: { x: 0 },
261
+ animation: false,
151
262
  renderItem: (params: any, api: any) => {
152
263
  const index = params.dataIndex;
153
264
  if (index === 0) return null;
@@ -173,6 +284,9 @@ export class FillRenderer implements SeriesRenderer {
173
284
  const gc = gradientColors[index] || gradientColors[index - 1];
174
285
  if (!gc) return null;
175
286
 
287
+ // Skip fully transparent gradient fills
288
+ if (gc.topOpacity < 0.01 && gc.bottomOpacity < 0.01) return null;
289
+
176
290
  // Convert colors to rgba strings with their opacities
177
291
  const topRgba = ColorUtils.toRgba(gc.topColor, gc.topOpacity);
178
292
  const bottomRgba = ColorUtils.toRgba(gc.bottomColor, gc.bottomOpacity);
@@ -200,6 +314,7 @@ export class FillRenderer implements SeriesRenderer {
200
314
  };
201
315
  },
202
316
  data: fillDataWithPrev,
317
+ silent: true,
203
318
  };
204
319
  }
205
320