@qfo/qfchart 0.8.5 → 0.8.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qfo/qfchart",
3
- "version": "0.8.5",
3
+ "version": "0.8.7",
4
4
  "description": "Professional financial charting library built on Apache ECharts with candlestick charts, technical indicators, and interactive drawing tools",
5
5
  "keywords": [
6
6
  "chart",
@@ -105,6 +105,33 @@ export class FillRenderer implements SeriesRenderer {
105
105
  const fillOpacity = fc ? fc.opacity : defaultFillOpacity;
106
106
  if (fillOpacity < 0.01) return null;
107
107
 
108
+ const fillColor = fc ? fc.color : defaultFillColor;
109
+
110
+ // Check if plots cross between bars
111
+ const diff1Prev = prevY1 - prevY2;
112
+ const diff1Curr = y1 - y2;
113
+ const plotsCross = (diff1Prev > 0 && diff1Curr < 0) || (diff1Prev < 0 && diff1Curr > 0);
114
+
115
+ if (plotsCross) {
116
+ const t = diff1Prev / (diff1Prev - diff1Curr);
117
+ const crossX = index - 1 + t;
118
+ const crossY = prevY1 + t * (y1 - prevY1);
119
+ const pCross = api.coord([crossX, crossY]);
120
+ const p1Prev = api.coord([index - 1, prevY1]);
121
+ const p1Curr = api.coord([index, y1]);
122
+ const p2Curr = api.coord([index, y2]);
123
+ const p2Prev = api.coord([index - 1, prevY2]);
124
+
125
+ return {
126
+ type: 'group',
127
+ children: [
128
+ { type: 'polygon', shape: { points: [p1Prev, pCross, p2Prev] }, style: { fill: fillColor, opacity: fillOpacity }, silent: true },
129
+ { type: 'polygon', shape: { points: [pCross, p1Curr, p2Curr] }, style: { fill: fillColor, opacity: fillOpacity }, silent: true },
130
+ ],
131
+ silent: true,
132
+ };
133
+ }
134
+
108
135
  const p1Prev = api.coord([index - 1, prevY1]);
109
136
  const p1Curr = api.coord([index, y1]);
110
137
  const p2Curr = api.coord([index, y2]);
@@ -116,7 +143,7 @@ export class FillRenderer implements SeriesRenderer {
116
143
  points: [p1Prev, p1Curr, p2Curr, p2Prev],
117
144
  },
118
145
  style: {
119
- fill: fc ? fc.color : defaultFillColor,
146
+ fill: fillColor,
120
147
  opacity: fillOpacity,
121
148
  },
122
149
  silent: true,
@@ -179,17 +206,36 @@ export class FillRenderer implements SeriesRenderer {
179
206
  const fc = fill.barColors[index];
180
207
  if (!fc || fc.opacity < 0.01) continue;
181
208
 
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
- });
209
+ const fillStyle = { fill: fc.color, opacity: fc.opacity };
210
+
211
+ // Check if plots cross between bars
212
+ const dPrev = (prevY1 as number) - (prevY2 as number);
213
+ const dCurr = (y1 as number) - (y2 as number);
214
+ const crosses = (dPrev > 0 && dCurr < 0) || (dPrev < 0 && dCurr > 0);
215
+
216
+ if (crosses) {
217
+ const t = dPrev / (dPrev - dCurr);
218
+ const crossX = index - 1 + t;
219
+ const crossY = (prevY1 as number) + t * ((y1 as number) - (prevY1 as number));
220
+ const pCross = api.coord([crossX, crossY]);
221
+ const p1Prev = api.coord([index - 1, prevY1]);
222
+ const p1Curr = api.coord([index, y1]);
223
+ const p2Curr = api.coord([index, y2]);
224
+ const p2Prev = api.coord([index - 1, prevY2]);
225
+ children.push({ type: 'polygon', shape: { points: [p1Prev, pCross, p2Prev] }, style: fillStyle, silent: true });
226
+ children.push({ type: 'polygon', shape: { points: [pCross, p1Curr, p2Curr] }, style: fillStyle, silent: true });
227
+ } else {
228
+ const p1Prev = api.coord([index - 1, prevY1]);
229
+ const p1Curr = api.coord([index, y1]);
230
+ const p2Curr = api.coord([index, y2]);
231
+ const p2Prev = api.coord([index - 1, prevY2]);
232
+ children.push({
233
+ type: 'polygon',
234
+ shape: { points: [p1Prev, p1Curr, p2Curr, p2Prev] },
235
+ style: fillStyle,
236
+ silent: true,
237
+ });
238
+ }
193
239
  }
194
240
 
195
241
  return children.length > 0 ? { type: 'group', children, silent: true } : null;
@@ -201,9 +247,15 @@ export class FillRenderer implements SeriesRenderer {
201
247
 
202
248
  /**
203
249
  * Render a gradient fill between two plots.
204
- * Uses per-bar top_value/bottom_value as the actual Y boundaries (not the raw plot values).
205
- * A vertical linear gradient goes from top_color (at top_value) to bottom_color (at bottom_value).
206
- * When top_value or bottom_value is na/NaN, the fill is hidden for that bar.
250
+ *
251
+ * TradingView gradient fill semantics:
252
+ * - The polygon is ALWAYS clipped to the area between plot1 and plot2
253
+ * - top_value / bottom_value define the COLOR GRADIENT RANGE, not the polygon bounds
254
+ * - top_color maps to top_value, bottom_color maps to bottom_value
255
+ * - When top_color or bottom_color is na, that bar is hidden
256
+ *
257
+ * So the polygon shape uses plot1/plot2 data, but the gradient color ramp
258
+ * is mapped based on where the plot values fall within [bottom_value, top_value].
207
259
  */
208
260
  private renderGradientFill(
209
261
  seriesName: string,
@@ -215,52 +267,72 @@ export class FillRenderer implements SeriesRenderer {
215
267
  optionsArray: any[],
216
268
  plotOptions: any
217
269
  ): any {
218
- // Build per-bar gradient data from optionsArray
219
- // Each entry has: { top_value, bottom_value, top_color, bottom_color }
270
+ // Build per-bar gradient info
220
271
  interface GradientBar {
221
- topValue: number | null;
222
- bottomValue: number | null;
272
+ topValue: number; // Color gradient range top
273
+ bottomValue: number; // Color gradient range bottom
223
274
  topColor: string;
224
275
  topOpacity: number;
225
276
  bottomColor: string;
226
277
  bottomOpacity: number;
278
+ topIsNa: boolean;
279
+ btmIsNa: boolean;
227
280
  }
228
281
  const gradientBars: (GradientBar | null)[] = [];
229
282
 
283
+ const isNaColor = (c: any): boolean => {
284
+ if (c === null || c === undefined) return true;
285
+ if (typeof c === 'number' && isNaN(c)) return true;
286
+ if (c === 'na' || c === 'NaN' || c === '') return true;
287
+ return false;
288
+ };
289
+
230
290
  for (let i = 0; i < totalDataLength; i++) {
231
291
  const opts = optionsArray?.[i];
232
- if (opts && opts.top_color !== undefined) {
292
+ if (opts && (opts.top_color !== undefined || opts.bottom_color !== undefined)) {
293
+ const topIsNa = isNaColor(opts.top_color);
294
+ const btmIsNa = isNaColor(opts.bottom_color);
295
+
296
+ if (topIsNa && btmIsNa) {
297
+ gradientBars[i] = null;
298
+ continue;
299
+ }
300
+
301
+ const topC = topIsNa ? { color: 'rgba(0,0,0,0)', opacity: 0 } : ColorUtils.parseColor(opts.top_color);
302
+ const btmC = btmIsNa ? { color: 'rgba(0,0,0,0)', opacity: 0 } : ColorUtils.parseColor(opts.bottom_color);
303
+
233
304
  const tv = opts.top_value;
234
305
  const bv = opts.bottom_value;
235
- // na/NaN/null/undefined → null (hidden bar)
236
306
  const topVal = (tv == null || (typeof tv === 'number' && isNaN(tv))) ? null : tv;
237
307
  const btmVal = (bv == null || (typeof bv === 'number' && isNaN(bv))) ? null : bv;
308
+ if (topVal == null || btmVal == null) {
309
+ gradientBars[i] = null;
310
+ continue;
311
+ }
238
312
 
239
- const top = ColorUtils.parseColor(opts.top_color);
240
- const bottom = ColorUtils.parseColor(opts.bottom_color);
241
313
  gradientBars[i] = {
242
314
  topValue: topVal,
243
315
  bottomValue: btmVal,
244
- topColor: top.color,
245
- topOpacity: top.opacity,
246
- bottomColor: bottom.color,
247
- bottomOpacity: bottom.opacity,
316
+ topColor: topC.color,
317
+ topOpacity: topC.opacity,
318
+ bottomColor: btmC.color,
319
+ bottomOpacity: btmC.opacity,
320
+ topIsNa,
321
+ btmIsNa,
248
322
  };
249
323
  } else {
250
324
  gradientBars[i] = null;
251
325
  }
252
326
  }
253
327
 
254
- // Create fill data using top_value/bottom_value as Y boundaries
328
+ // Build fill data using PLOT values as polygon boundaries
255
329
  const fillData: any[] = [];
256
330
  for (let i = 0; i < totalDataLength; i++) {
257
- const gb = gradientBars[i];
258
- const prevGb = i > 0 ? gradientBars[i - 1] : null;
259
- const topY = gb?.topValue ?? null;
260
- const btmY = gb?.bottomValue ?? null;
261
- const prevTopY = prevGb?.topValue ?? null;
262
- const prevBtmY = prevGb?.bottomValue ?? null;
263
- fillData.push([i, topY, btmY, prevTopY, prevBtmY]);
331
+ const y1 = plot1Data[i];
332
+ const y2 = plot2Data[i];
333
+ const prevY1 = i > 0 ? plot1Data[i - 1] : null;
334
+ const prevY2 = i > 0 ? plot2Data[i - 1] : null;
335
+ fillData.push([i, y1, y2, prevY1, prevY2]);
264
336
  }
265
337
 
266
338
  return {
@@ -276,51 +348,99 @@ export class FillRenderer implements SeriesRenderer {
276
348
  const index = params.dataIndex;
277
349
  if (index === 0) return null;
278
350
 
279
- const topY = api.value(1);
280
- const btmY = api.value(2);
281
- const prevTopY = api.value(3);
282
- const prevBtmY = api.value(4);
351
+ const y1 = api.value(1);
352
+ const y2 = api.value(2);
353
+ const prevY1 = api.value(3);
354
+ const prevY2 = api.value(4);
283
355
 
284
- // Skip when any boundary is na (hidden bar)
285
356
  if (
286
- topY == null || btmY == null || prevTopY == null || prevBtmY == null ||
287
- isNaN(topY) || isNaN(btmY) || isNaN(prevTopY) || isNaN(prevBtmY)
357
+ y1 == null || y2 == null || prevY1 == null || prevY2 == null ||
358
+ isNaN(y1) || isNaN(y2) || isNaN(prevY1) || isNaN(prevY2)
288
359
  ) {
289
360
  return null;
290
361
  }
291
362
 
292
- // Get gradient colors for this bar
293
363
  const gb = gradientBars[index];
294
364
  if (!gb) return null;
295
365
 
296
- // Skip fully transparent gradient fills
297
- if (gb.topOpacity < 0.01 && gb.bottomOpacity < 0.01) return null;
366
+ const gradRange = gb.topValue - gb.bottomValue;
367
+ const hasNaSide = gb.topIsNa || gb.btmIsNa;
298
368
 
299
- const topRgba = ColorUtils.toRgba(gb.topColor, gb.topOpacity);
300
- const bottomRgba = ColorUtils.toRgba(gb.bottomColor, gb.bottomOpacity);
369
+ // Compute gradient color stops for the polygon's y-range
370
+ const colorAtY = (yVal: number): string => {
371
+ let t: number;
372
+ if (Math.abs(gradRange) < 1e-10) { t = 0.5; }
373
+ else { t = 1 - (yVal - gb.bottomValue) / gradRange; }
374
+ t = Math.max(0, Math.min(1, t));
301
375
 
302
- const pTopPrev = api.coord([index - 1, prevTopY]);
303
- const pTopCurr = api.coord([index, topY]);
304
- const pBtmCurr = api.coord([index, btmY]);
305
- const pBtmPrev = api.coord([index - 1, prevBtmY]);
376
+ if (gb.topIsNa) {
377
+ return ColorUtils.toRgba(gb.bottomColor, gb.bottomOpacity * t);
378
+ }
379
+ if (gb.btmIsNa) {
380
+ return ColorUtils.toRgba(gb.topColor, gb.topOpacity * (1 - t));
381
+ }
382
+ return ColorUtils.interpolateColor(gb.topColor, gb.topOpacity, gb.bottomColor, gb.bottomOpacity, t);
383
+ };
306
384
 
307
- return {
385
+ // Build polygon between the two plot lines
386
+ const p1Prev = api.coord([index - 1, prevY1]);
387
+ const p1Curr = api.coord([index, y1]);
388
+ const p2Curr = api.coord([index, y2]);
389
+ const p2Prev = api.coord([index - 1, prevY2]);
390
+
391
+ // Vertical gradient: top of polygon to bottom of polygon
392
+ const polyTop = Math.max(y1, y2, prevY1, prevY2);
393
+ const polyBot = Math.min(y1, y2, prevY1, prevY2);
394
+
395
+ const polygon: any = {
308
396
  type: 'polygon',
309
- shape: {
310
- points: [pTopPrev, pTopCurr, pBtmCurr, pBtmPrev],
311
- },
397
+ shape: { points: [p1Prev, p1Curr, p2Curr, p2Prev] },
312
398
  style: {
313
399
  fill: {
314
400
  type: 'linear',
315
401
  x: 0, y: 0, x2: 0, y2: 1,
316
402
  colorStops: [
317
- { offset: 0, color: topRgba },
318
- { offset: 1, color: bottomRgba },
403
+ { offset: 0, color: colorAtY(polyTop) },
404
+ { offset: 1, color: colorAtY(polyBot) },
319
405
  ],
320
406
  },
321
407
  },
322
408
  silent: true,
323
409
  };
410
+
411
+ // When one color is na, clip the polygon to only the valid side of plot2.
412
+ if (hasNaSide) {
413
+ const cs = params.coordSys;
414
+ const zeroPixelPrev = api.coord([index - 1, prevY2])[1];
415
+ const zeroPixelCurr = api.coord([index, y2])[1];
416
+ // Use the average zero-line pixel position for this segment
417
+ const zeroPixelY = (zeroPixelPrev + zeroPixelCurr) / 2;
418
+
419
+ let clipY: number, clipH: number;
420
+ if (gb.btmIsNa) {
421
+ // Only draw above plot2
422
+ clipY = cs.y;
423
+ clipH = zeroPixelY - cs.y;
424
+ } else {
425
+ // Only draw below plot2
426
+ clipY = zeroPixelY;
427
+ clipH = cs.y + cs.height - zeroPixelY;
428
+ }
429
+
430
+ if (clipH <= 0) return null;
431
+
432
+ return {
433
+ type: 'group',
434
+ children: [polygon],
435
+ clipPath: {
436
+ type: 'rect',
437
+ shape: { x: cs.x, y: clipY, width: cs.width, height: clipH },
438
+ },
439
+ silent: true,
440
+ };
441
+ }
442
+
443
+ return polygon;
324
444
  },
325
445
  data: fillData,
326
446
  silent: true,
@@ -64,7 +64,9 @@ export class PolylineRenderer implements SeriesRenderer {
64
64
  for (const pt of points) {
65
65
  let x: number;
66
66
  if (useBi) {
67
- x = (pt.index ?? 0) + offset;
67
+ const idx = pt.index;
68
+ if (idx == null || (typeof idx === 'number' && isNaN(idx))) { skipPoly = true; break; }
69
+ x = idx + offset;
68
70
  } else {
69
71
  x = resolveXCoord(pt.time ?? 0, 'bt', offset, timeToIndex, marketData);
70
72
  if (isNaN(x)) { skipPoly = true; break; }
@@ -36,10 +36,11 @@ export class ColorUtils {
36
36
  };
37
37
  }
38
38
 
39
- // For 6-digit hex or named colors, default opacity to 0.3 for fill areas
39
+ // For 6-digit hex or named colors, return full opacity.
40
+ // Individual renderers (fill, gradient) apply their own opacity as needed.
40
41
  return {
41
42
  color: colorStr,
42
- opacity: 0.3,
43
+ opacity: 1.0,
43
44
  };
44
45
  }
45
46
 
@@ -74,4 +75,35 @@ export class ColorUtils {
74
75
  // Fallback: return color as-is
75
76
  return color;
76
77
  }
78
+
79
+ /**
80
+ * Parse a color string into {r, g, b} components.
81
+ */
82
+ private static toRGB(color: string): { r: number; g: number; b: number } {
83
+ const rgbMatch = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
84
+ if (rgbMatch) return { r: +rgbMatch[1], g: +rgbMatch[2], b: +rgbMatch[3] };
85
+
86
+ const hexMatch = color.match(/^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})/);
87
+ if (hexMatch) return { r: parseInt(hexMatch[1], 16), g: parseInt(hexMatch[2], 16), b: parseInt(hexMatch[3], 16) };
88
+
89
+ return { r: 128, g: 128, b: 128 };
90
+ }
91
+
92
+ /**
93
+ * Linearly interpolate between two colors at a given t (0 = colorA, 1 = colorB).
94
+ * Returns an rgba() string.
95
+ */
96
+ public static interpolateColor(
97
+ colorA: string, opacityA: number,
98
+ colorB: string, opacityB: number,
99
+ t: number,
100
+ ): string {
101
+ const a = this.toRGB(colorA);
102
+ const b = this.toRGB(colorB);
103
+ const r = Math.round(a.r + (b.r - a.r) * t);
104
+ const g = Math.round(a.g + (b.g - a.g) * t);
105
+ const bl = Math.round(a.b + (b.b - a.b) * t);
106
+ const op = opacityA + (opacityB - opacityA) * t;
107
+ return `rgba(${r},${g},${bl},${op})`;
108
+ }
77
109
  }