@qfo/qfchart 0.8.5 → 0.8.6
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/dist/qfchart.min.browser.js +12 -12
- package/dist/qfchart.min.es.js +12 -12
- package/package.json +1 -1
- package/src/components/renderers/FillRenderer.ts +177 -57
- package/src/utils/ColorUtils.ts +34 -2
package/package.json
CHANGED
|
@@ -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:
|
|
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
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
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
|
|
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
|
|
222
|
-
bottomValue: number
|
|
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:
|
|
245
|
-
topOpacity:
|
|
246
|
-
bottomColor:
|
|
247
|
-
bottomOpacity:
|
|
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
|
-
//
|
|
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
|
|
258
|
-
const
|
|
259
|
-
const
|
|
260
|
-
const
|
|
261
|
-
|
|
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
|
|
280
|
-
const
|
|
281
|
-
const
|
|
282
|
-
const
|
|
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
|
-
|
|
287
|
-
isNaN(
|
|
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
|
-
|
|
297
|
-
|
|
366
|
+
const gradRange = gb.topValue - gb.bottomValue;
|
|
367
|
+
const hasNaSide = gb.topIsNa || gb.btmIsNa;
|
|
298
368
|
|
|
299
|
-
|
|
300
|
-
const
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
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:
|
|
318
|
-
{ offset: 1, color:
|
|
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,
|
package/src/utils/ColorUtils.ts
CHANGED
|
@@ -36,10 +36,11 @@ export class ColorUtils {
|
|
|
36
36
|
};
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
// For 6-digit hex or named colors,
|
|
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
|
|
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
|
}
|