@qfo/qfchart 0.7.1 → 0.7.3

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.
@@ -3,7 +3,7 @@ import { ColorUtils } from '../../utils/ColorUtils';
3
3
 
4
4
  export class FillRenderer implements SeriesRenderer {
5
5
  render(context: RenderContext): any {
6
- const { seriesName, xAxisIndex, yAxisIndex, plotOptions, plotDataArrays, indicatorId, plotName } = context;
6
+ const { seriesName, xAxisIndex, yAxisIndex, plotOptions, plotDataArrays, indicatorId, plotName, optionsArray } = context;
7
7
  const totalDataLength = context.dataArray.length; // Use length from dataArray placeholder
8
8
 
9
9
  // Fill plots reference other plots to fill the area between them
@@ -23,7 +23,18 @@ export class FillRenderer implements SeriesRenderer {
23
23
  return null;
24
24
  }
25
25
 
26
- // Parse color to extract opacity
26
+ // Detect gradient fill mode
27
+ const isGradient = plotOptions.gradient === true;
28
+
29
+ if (isGradient) {
30
+ return this.renderGradientFill(
31
+ seriesName, xAxisIndex, yAxisIndex,
32
+ plot1Data, plot2Data, totalDataLength,
33
+ optionsArray, plotOptions
34
+ );
35
+ }
36
+
37
+ // --- Simple (solid color) fill ---
27
38
  const { color: fillColor, opacity: fillOpacity } = ColorUtils.parseColor(plotOptions.color || 'rgba(128, 128, 128, 0.2)');
28
39
 
29
40
  // Create fill data with previous values for smooth polygon rendering
@@ -37,54 +48,37 @@ export class FillRenderer implements SeriesRenderer {
37
48
  fillDataWithPrev.push([i, y1, y2, prevY1, prevY2]);
38
49
  }
39
50
 
40
- // Add fill series with smooth area rendering
41
51
  return {
42
52
  name: seriesName,
43
53
  type: 'custom',
44
54
  xAxisIndex: xAxisIndex,
45
55
  yAxisIndex: yAxisIndex,
46
- z: 1, // Behind plot lines (z=2) and candles (z=5), above grid background
56
+ z: 1,
47
57
  renderItem: (params: any, api: any) => {
48
58
  const index = params.dataIndex;
49
-
50
- // Skip first point (no previous to connect to)
51
59
  if (index === 0) return null;
52
60
 
53
- const y1 = api.value(1); // Current upper
54
- const y2 = api.value(2); // Current lower
55
- const prevY1 = api.value(3); // Previous upper
56
- const prevY2 = api.value(4); // Previous lower
61
+ const y1 = api.value(1);
62
+ const y2 = api.value(2);
63
+ const prevY1 = api.value(3);
64
+ const prevY2 = api.value(4);
57
65
 
58
- // Skip if any value is null/NaN
59
66
  if (
60
- y1 === null ||
61
- y2 === null ||
62
- prevY1 === null ||
63
- prevY2 === null ||
64
- isNaN(y1) ||
65
- isNaN(y2) ||
66
- isNaN(prevY1) ||
67
- isNaN(prevY2)
67
+ y1 === null || y2 === null || prevY1 === null || prevY2 === null ||
68
+ isNaN(y1) || isNaN(y2) || isNaN(prevY1) || isNaN(prevY2)
68
69
  ) {
69
70
  return null;
70
71
  }
71
72
 
72
- // Get pixel coordinates for all 4 points
73
- const p1Prev = api.coord([index - 1, prevY1]); // Previous upper
74
- const p1Curr = api.coord([index, y1]); // Current upper
75
- const p2Curr = api.coord([index, y2]); // Current lower
76
- const p2Prev = api.coord([index - 1, prevY2]); // Previous lower
73
+ const p1Prev = api.coord([index - 1, prevY1]);
74
+ const p1Curr = api.coord([index, y1]);
75
+ const p2Curr = api.coord([index, y2]);
76
+ const p2Prev = api.coord([index - 1, prevY2]);
77
77
 
78
- // Create a smooth polygon connecting the segments
79
78
  return {
80
79
  type: 'polygon',
81
80
  shape: {
82
- points: [
83
- p1Prev, // Top-left
84
- p1Curr, // Top-right
85
- p2Curr, // Bottom-right
86
- p2Prev, // Bottom-left
87
- ],
81
+ points: [p1Prev, p1Curr, p2Curr, p2Prev],
88
82
  },
89
83
  style: {
90
84
  fill: fillColor,
@@ -96,4 +90,117 @@ export class FillRenderer implements SeriesRenderer {
96
90
  data: fillDataWithPrev,
97
91
  };
98
92
  }
93
+
94
+ /**
95
+ * Render a gradient fill between two plots.
96
+ * Uses a vertical linear gradient from top_color (at the upper boundary)
97
+ * to bottom_color (at the lower boundary) for each polygon segment.
98
+ */
99
+ private renderGradientFill(
100
+ seriesName: string,
101
+ xAxisIndex: number,
102
+ yAxisIndex: number,
103
+ plot1Data: (number | null)[],
104
+ plot2Data: (number | null)[],
105
+ totalDataLength: number,
106
+ optionsArray: any[],
107
+ plotOptions: any
108
+ ): any {
109
+ // Build per-bar gradient color arrays from optionsArray
110
+ // Each entry in optionsArray has: { top_value, bottom_value, top_color, bottom_color }
111
+ const gradientColors: { topColor: string; topOpacity: number; bottomColor: string; bottomOpacity: number }[] = [];
112
+
113
+ for (let i = 0; i < totalDataLength; i++) {
114
+ const opts = optionsArray?.[i];
115
+ if (opts && opts.top_color !== undefined) {
116
+ const top = ColorUtils.parseColor(opts.top_color);
117
+ const bottom = ColorUtils.parseColor(opts.bottom_color);
118
+ gradientColors[i] = {
119
+ topColor: top.color,
120
+ topOpacity: top.opacity,
121
+ bottomColor: bottom.color,
122
+ bottomOpacity: bottom.opacity,
123
+ };
124
+ } else {
125
+ // Fallback: use a default semi-transparent fill
126
+ gradientColors[i] = {
127
+ topColor: 'rgba(128,128,128,0.2)',
128
+ topOpacity: 0.2,
129
+ bottomColor: 'rgba(128,128,128,0.2)',
130
+ bottomOpacity: 0.2,
131
+ };
132
+ }
133
+ }
134
+
135
+ // Create fill data with previous values
136
+ const fillDataWithPrev: any[] = [];
137
+ for (let i = 0; i < totalDataLength; i++) {
138
+ const y1 = plot1Data[i];
139
+ const y2 = plot2Data[i];
140
+ const prevY1 = i > 0 ? plot1Data[i - 1] : null;
141
+ const prevY2 = i > 0 ? plot2Data[i - 1] : null;
142
+ fillDataWithPrev.push([i, y1, y2, prevY1, prevY2]);
143
+ }
144
+
145
+ return {
146
+ name: seriesName,
147
+ type: 'custom',
148
+ xAxisIndex: xAxisIndex,
149
+ yAxisIndex: yAxisIndex,
150
+ z: 1,
151
+ renderItem: (params: any, api: any) => {
152
+ const index = params.dataIndex;
153
+ if (index === 0) return null;
154
+
155
+ const y1 = api.value(1);
156
+ const y2 = api.value(2);
157
+ const prevY1 = api.value(3);
158
+ const prevY2 = api.value(4);
159
+
160
+ if (
161
+ y1 === null || y2 === null || prevY1 === null || prevY2 === null ||
162
+ isNaN(y1) || isNaN(y2) || isNaN(prevY1) || isNaN(prevY2)
163
+ ) {
164
+ return null;
165
+ }
166
+
167
+ const p1Prev = api.coord([index - 1, prevY1]);
168
+ const p1Curr = api.coord([index, y1]);
169
+ const p2Curr = api.coord([index, y2]);
170
+ const p2Prev = api.coord([index - 1, prevY2]);
171
+
172
+ // Get gradient colors for this bar
173
+ const gc = gradientColors[index] || gradientColors[index - 1];
174
+ if (!gc) return null;
175
+
176
+ // Convert colors to rgba strings with their opacities
177
+ const topRgba = ColorUtils.toRgba(gc.topColor, gc.topOpacity);
178
+ const bottomRgba = ColorUtils.toRgba(gc.bottomColor, gc.bottomOpacity);
179
+
180
+ // Determine if plot1 is above plot2 (in value space, higher value = higher on chart)
181
+ // We want top_color at the higher value, bottom_color at the lower value
182
+ const plot1IsAbove = y1 >= y2;
183
+
184
+ return {
185
+ type: 'polygon',
186
+ shape: {
187
+ points: [p1Prev, p1Curr, p2Curr, p2Prev],
188
+ },
189
+ style: {
190
+ fill: {
191
+ type: 'linear',
192
+ x: 0, y: 0, x2: 0, y2: 1, // vertical gradient
193
+ colorStops: [
194
+ { offset: 0, color: plot1IsAbove ? topRgba : bottomRgba },
195
+ { offset: 1, color: plot1IsAbove ? bottomRgba : topRgba },
196
+ ],
197
+ },
198
+ },
199
+ silent: true,
200
+ };
201
+ },
202
+ data: fillDataWithPrev,
203
+ };
204
+ }
205
+
99
206
  }
@@ -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
+ }
@@ -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 },
@@ -34,21 +34,12 @@ export class PolylineRenderer implements SeriesRenderer {
34
34
  return { name: seriesName, type: 'custom', xAxisIndex, yAxisIndex, data: [], silent: true };
35
35
  }
36
36
 
37
- // Compute y-range across all polylines for axis scaling
38
- let yMin = Infinity, yMax = -Infinity;
39
- for (const pl of polyObjects) {
40
- for (const pt of pl.points) {
41
- const p = pt.price ?? 0;
42
- if (p < yMin) yMin = p;
43
- if (p > yMax) yMax = p;
44
- }
45
- }
46
-
47
37
  // Use a SINGLE data entry spanning the full x-range so renderItem is always called.
48
38
  // ECharts filters a data item only when ALL its x-dimensions are on the same side
49
39
  // of the visible window. With dims 0=0 and 1=lastBar the item always straddles
50
40
  // the viewport, so renderItem fires exactly once regardless of scroll position.
51
- // Dims 2/3 are yMin/yMax for axis scaling.
41
+ // Note: We do NOT encode y-dimensions — drawing objects should not influence the
42
+ // y-axis auto-scaling.
52
43
  const totalBars = (context.candlestickData?.length || 0) + offset;
53
44
  const lastBarIndex = Math.max(0, totalBars - 1);
54
45
 
@@ -126,9 +117,11 @@ export class PolylineRenderer implements SeriesRenderer {
126
117
 
127
118
  return { type: 'group', children };
128
119
  },
129
- data: [[0, lastBarIndex, yMin, yMax]],
120
+ data: [[0, lastBarIndex]],
130
121
  clip: true,
131
- encode: { x: [0, 1], y: [2, 3] },
122
+ encode: { x: [0, 1] },
123
+ // Prevent ECharts visual system from overriding element colors with palette
124
+ itemStyle: { color: 'transparent', borderColor: 'transparent' },
132
125
  z: 12,
133
126
  silent: true,
134
127
  emphasis: { disabled: true },
@@ -1,32 +1,77 @@
1
- export class ColorUtils {
2
- /**
3
- * Parse color string and extract opacity
4
- * Supports: hex (#RRGGBB), named colors (green, red), rgba(r,g,b,a), rgb(r,g,b)
5
- */
6
- public static parseColor(colorStr: string): { color: string; opacity: number } {
7
- if (!colorStr) {
8
- return { color: '#888888', opacity: 0.2 };
9
- }
10
-
11
- // Check for rgba format
12
- const rgbaMatch = colorStr.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
13
- if (rgbaMatch) {
14
- const r = rgbaMatch[1];
15
- const g = rgbaMatch[2];
16
- const b = rgbaMatch[3];
17
- const a = rgbaMatch[4] ? parseFloat(rgbaMatch[4]) : 1;
18
-
19
- // Return rgb color and separate opacity
20
- return {
21
- color: `rgb(${r},${g},${b})`,
22
- opacity: a,
23
- };
24
- }
25
-
26
- // For hex or named colors, default opacity to 0.3 for fill areas
27
- return {
28
- color: colorStr,
29
- opacity: 0.3,
30
- };
31
- }
32
- }
1
+ export class ColorUtils {
2
+ /**
3
+ * Parse color string and extract opacity
4
+ * Supports: hex (#RRGGBB, #RRGGBBAA), named colors (green, red), rgba(r,g,b,a), rgb(r,g,b)
5
+ */
6
+ public static parseColor(colorStr: string): { color: string; opacity: number } {
7
+ if (!colorStr) {
8
+ return { color: '#888888', opacity: 0.2 };
9
+ }
10
+
11
+ // Check for rgba format
12
+ const rgbaMatch = colorStr.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
13
+ if (rgbaMatch) {
14
+ const r = rgbaMatch[1];
15
+ const g = rgbaMatch[2];
16
+ const b = rgbaMatch[3];
17
+ const a = rgbaMatch[4] ? parseFloat(rgbaMatch[4]) : 1;
18
+
19
+ // Return rgb color and separate opacity
20
+ return {
21
+ color: `rgb(${r},${g},${b})`,
22
+ opacity: a,
23
+ };
24
+ }
25
+
26
+ // Check for 8-digit hex with alpha (#RRGGBBAA)
27
+ const hex8Match = colorStr.match(/^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/);
28
+ if (hex8Match) {
29
+ const r = parseInt(hex8Match[1], 16);
30
+ const g = parseInt(hex8Match[2], 16);
31
+ const b = parseInt(hex8Match[3], 16);
32
+ const a = parseInt(hex8Match[4], 16) / 255;
33
+ return {
34
+ color: `rgb(${r},${g},${b})`,
35
+ opacity: a,
36
+ };
37
+ }
38
+
39
+ // For 6-digit hex or named colors, default opacity to 0.3 for fill areas
40
+ return {
41
+ color: colorStr,
42
+ opacity: 0.3,
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Convert a parsed color + opacity to an rgba string.
48
+ */
49
+ public static toRgba(color: string, opacity: number): string {
50
+ // If already rgba/rgb format
51
+ const rgbMatch = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
52
+ if (rgbMatch) {
53
+ return `rgba(${rgbMatch[1]},${rgbMatch[2]},${rgbMatch[3]},${opacity})`;
54
+ }
55
+
56
+ // Handle 6-digit hex colors
57
+ const hexMatch = color.match(/^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/);
58
+ if (hexMatch) {
59
+ const r = parseInt(hexMatch[1], 16);
60
+ const g = parseInt(hexMatch[2], 16);
61
+ const b = parseInt(hexMatch[3], 16);
62
+ return `rgba(${r},${g},${b},${opacity})`;
63
+ }
64
+
65
+ // Handle 8-digit hex colors (#RRGGBBAA) — use alpha from hex, override with opacity
66
+ const hex8Match = color.match(/^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/);
67
+ if (hex8Match) {
68
+ const r = parseInt(hex8Match[1], 16);
69
+ const g = parseInt(hex8Match[2], 16);
70
+ const b = parseInt(hex8Match[3], 16);
71
+ return `rgba(${r},${g},${b},${opacity})`;
72
+ }
73
+
74
+ // Fallback: return color as-is
75
+ return color;
76
+ }
77
+ }