@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.
@@ -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
 
@@ -1,20 +1,67 @@
1
- import { SeriesRenderer, RenderContext } from './SeriesRenderer';
2
-
3
- export class HistogramRenderer implements SeriesRenderer {
4
- render(context: RenderContext): any {
5
- const { seriesName, xAxisIndex, yAxisIndex, dataArray, colorArray, plotOptions } = context;
6
- const defaultColor = '#2962ff';
7
-
8
- return {
9
- name: seriesName,
10
- type: 'bar',
11
- xAxisIndex: xAxisIndex,
12
- yAxisIndex: yAxisIndex,
13
- data: dataArray.map((val, i) => ({
14
- value: val,
15
- itemStyle: colorArray[i] ? { color: colorArray[i] } : undefined,
16
- })),
17
- itemStyle: { color: plotOptions.color || defaultColor },
18
- };
19
- }
20
- }
1
+ import { SeriesRenderer, RenderContext } from './SeriesRenderer';
2
+
3
+ export class HistogramRenderer implements SeriesRenderer {
4
+ render(context: RenderContext): any {
5
+ const { seriesName, xAxisIndex, yAxisIndex, dataArray, colorArray, plotOptions } = context;
6
+ const defaultColor = '#2962ff';
7
+ const histbase: number = plotOptions.histbase ?? 0;
8
+ const isColumns = plotOptions.style === 'columns';
9
+ const linewidth: number = plotOptions.linewidth ?? 1;
10
+
11
+ // Build data array: [index, value, color]
12
+ const customData = dataArray.map((val: number | null, i: number) => {
13
+ if (val === null || val === undefined || (typeof val === 'number' && isNaN(val))) return null;
14
+ return [i, val, colorArray[i] || plotOptions.color || defaultColor];
15
+ });
16
+
17
+ return {
18
+ name: seriesName,
19
+ type: 'custom',
20
+ xAxisIndex: xAxisIndex,
21
+ yAxisIndex: yAxisIndex,
22
+ renderItem: (params: any, api: any) => {
23
+ const idx = api.value(0);
24
+ const value = api.value(1);
25
+ const color = api.value(2);
26
+
27
+ if (value === null || value === undefined || isNaN(value)) {
28
+ return null;
29
+ }
30
+
31
+ const basePos = api.coord([idx, histbase]);
32
+ const valuePos = api.coord([idx, value]);
33
+ const candleWidth = api.size([1, 0])[0];
34
+
35
+ // Columns: thick bars (60% of candle width)
36
+ // Histogram: thin bars — scale with linewidth (like TradingView)
37
+ let barWidth: number;
38
+ if (isColumns) {
39
+ barWidth = candleWidth * 0.6;
40
+ } else {
41
+ // Thin line-like bars: linewidth controls pixel width (min 1px)
42
+ barWidth = Math.max(1, linewidth);
43
+ }
44
+
45
+ const x = basePos[0];
46
+ const yBase = basePos[1];
47
+ const yValue = valuePos[1];
48
+ const top = Math.min(yBase, yValue);
49
+ const height = Math.abs(yValue - yBase);
50
+
51
+ return {
52
+ type: 'rect',
53
+ shape: {
54
+ x: x - barWidth / 2,
55
+ y: top,
56
+ width: barWidth,
57
+ height: height || 1, // Minimum 1px for zero-height bars
58
+ },
59
+ style: {
60
+ fill: color,
61
+ },
62
+ };
63
+ },
64
+ data: customData.filter((d: any) => d !== null),
65
+ };
66
+ }
67
+ }
@@ -24,14 +24,19 @@ export class LabelRenderer implements SeriesRenderer {
24
24
 
25
25
  const labelData = labelObjects
26
26
  .map((lbl) => {
27
- const text = lbl.text || '';
28
- const color = lbl.color || '#2962ff';
29
- const textcolor = lbl.textcolor || '#ffffff';
30
- const yloc = lbl.yloc || 'price';
31
- const styleRaw = lbl.style || 'style_label_down';
32
- const size = lbl.size || 'normal';
33
- const textalign = lbl.textalign || 'align_center';
34
- const tooltip = lbl.tooltip || '';
27
+ // Resolve any function/Series values that may not have been
28
+ // resolved at PineTS level (e.g. setters that skip _resolve()).
29
+ const resolve = (v: any) => typeof v === 'function' ? v() : v;
30
+
31
+ const text = resolve(lbl.text) || '';
32
+ const rawColor = resolve(lbl.color);
33
+ const color = (rawColor != null && rawColor !== '') ? rawColor : 'transparent';
34
+ const textcolor = resolve(lbl.textcolor) || '#ffffff';
35
+ const yloc = resolve(lbl.yloc) || 'price';
36
+ const styleRaw = resolve(lbl.style) || 'style_label_down';
37
+ const size = resolve(lbl.size) || 'normal';
38
+ const textalign = resolve(lbl.textalign) || 'align_center';
39
+ const tooltip = resolve(lbl.tooltip) || '';
35
40
 
36
41
  // Map Pine style string to shape name for ShapeUtils
37
42
  const shape = this.styleToShape(styleRaw);
@@ -160,7 +165,24 @@ export class LabelRenderer implements SeriesRenderer {
160
165
  };
161
166
 
162
167
  if (tooltip) {
163
- item.tooltip = { formatter: tooltip };
168
+ // Store tooltip text for the custom tooltip overlay in QFChart.ts.
169
+ // ECharts mouseover event can read this from params.data._tooltipText.
170
+ item._tooltipText = tooltip;
171
+ // Enable emphasis for this item so ECharts fires mouseover/mouseout
172
+ // events, but prevent any visual change by mirroring normal styles.
173
+ item.emphasis = {
174
+ scale: false,
175
+ itemStyle: { color: color },
176
+ label: {
177
+ show: item.label.show,
178
+ color: textcolor,
179
+ fontSize: fontSize,
180
+ fontWeight: 'bold',
181
+ },
182
+ };
183
+ } else {
184
+ // No tooltip: fully disable emphasis (no hover interaction)
185
+ item.emphasis = { disabled: true };
164
186
  }
165
187
 
166
188
  return item;
@@ -174,6 +196,10 @@ export class LabelRenderer implements SeriesRenderer {
174
196
  yAxisIndex: yAxisIndex,
175
197
  data: labelData,
176
198
  z: 20,
199
+ // Per-item emphasis: disabled for labels without tooltips,
200
+ // scale:false for labels with tooltips (allows hover for custom tooltip).
201
+ animation: false, // Prevent labels disappearing on zoom
202
+ clip: false, // Keep labels visible when partially outside viewport
177
203
  };
178
204
  }
179
205
 
@@ -37,20 +37,12 @@ export class LinefillRenderer implements SeriesRenderer {
37
37
  return { name: seriesName, type: 'custom', xAxisIndex, yAxisIndex, data: [], silent: true };
38
38
  }
39
39
 
40
- // Compute y-range for axis scaling
41
- let yMin = Infinity, yMax = -Infinity;
42
- for (const lf of fillObjects) {
43
- for (const y of [lf.line1.y1, lf.line1.y2, lf.line2.y1, lf.line2.y2]) {
44
- if (y < yMin) yMin = y;
45
- if (y > yMax) yMax = y;
46
- }
47
- }
48
-
49
40
  // Use a SINGLE data entry spanning the full x-range so renderItem is always called.
50
41
  // ECharts filters a data item only when ALL its x-dimensions are on the same side
51
42
  // of the visible window. With dims 0=0 and 1=lastBar the item always straddles
52
43
  // the viewport, so renderItem fires exactly once regardless of scroll position.
53
- // Dims 2/3 are yMin/yMax for axis scaling.
44
+ // Note: We do NOT encode y-dimensions — drawing objects should not influence the
45
+ // y-axis auto-scaling.
54
46
  const totalBars = (context.candlestickData?.length || 0) + offset;
55
47
  const lastBarIndex = Math.max(0, totalBars - 1);
56
48
 
@@ -103,9 +95,9 @@ export class LinefillRenderer implements SeriesRenderer {
103
95
 
104
96
  return { type: 'group', children };
105
97
  },
106
- data: [[0, lastBarIndex, yMin, yMax]],
98
+ data: [[0, lastBarIndex]],
107
99
  clip: true,
108
- encode: { x: [0, 1], y: [2, 3] },
100
+ encode: { x: [0, 1] },
109
101
  z: 10, // Behind lines (z=15) but above other elements
110
102
  silent: true,
111
103
  emphasis: { disabled: true },