@qfo/qfchart 0.8.4 → 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 +19 -18
- package/dist/qfchart.min.es.js +19 -18
- package/package.json +1 -1
- package/src/components/GraphicBuilder.ts +44 -0
- package/src/components/Indicator.ts +106 -106
- package/src/components/SeriesBuilder.ts +5 -0
- package/src/components/renderers/BoxRenderer.ts +7 -5
- package/src/components/renderers/DrawingLineRenderer.ts +7 -5
- package/src/components/renderers/FillRenderer.ts +187 -58
- package/src/components/renderers/LabelRenderer.ts +22 -9
- package/src/components/renderers/LineRenderer.ts +44 -44
- package/src/components/renderers/LinefillRenderer.ts +11 -8
- package/src/components/renderers/PolylineRenderer.ts +11 -4
- package/src/components/renderers/SeriesRenderer.ts +78 -0
- package/src/components/renderers/StepRenderer.ts +39 -39
- package/src/utils/ColorUtils.ts +34 -2
- package/src/utils/ShapeUtils.ts +5 -0
|
@@ -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,8 +247,15 @@ export class FillRenderer implements SeriesRenderer {
|
|
|
201
247
|
|
|
202
248
|
/**
|
|
203
249
|
* Render a gradient fill between two plots.
|
|
204
|
-
*
|
|
205
|
-
*
|
|
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].
|
|
206
259
|
*/
|
|
207
260
|
private renderGradientFill(
|
|
208
261
|
seriesName: string,
|
|
@@ -214,40 +267,72 @@ export class FillRenderer implements SeriesRenderer {
|
|
|
214
267
|
optionsArray: any[],
|
|
215
268
|
plotOptions: any
|
|
216
269
|
): any {
|
|
217
|
-
// Build per-bar gradient
|
|
218
|
-
|
|
219
|
-
|
|
270
|
+
// Build per-bar gradient info
|
|
271
|
+
interface GradientBar {
|
|
272
|
+
topValue: number; // Color gradient range top
|
|
273
|
+
bottomValue: number; // Color gradient range bottom
|
|
274
|
+
topColor: string;
|
|
275
|
+
topOpacity: number;
|
|
276
|
+
bottomColor: string;
|
|
277
|
+
bottomOpacity: number;
|
|
278
|
+
topIsNa: boolean;
|
|
279
|
+
btmIsNa: boolean;
|
|
280
|
+
}
|
|
281
|
+
const gradientBars: (GradientBar | null)[] = [];
|
|
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
|
+
};
|
|
220
289
|
|
|
221
290
|
for (let i = 0; i < totalDataLength; i++) {
|
|
222
291
|
const opts = optionsArray?.[i];
|
|
223
|
-
if (opts && opts.top_color !== undefined) {
|
|
224
|
-
const
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
+
|
|
304
|
+
const tv = opts.top_value;
|
|
305
|
+
const bv = opts.bottom_value;
|
|
306
|
+
const topVal = (tv == null || (typeof tv === 'number' && isNaN(tv))) ? null : tv;
|
|
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
|
+
}
|
|
312
|
+
|
|
313
|
+
gradientBars[i] = {
|
|
314
|
+
topValue: topVal,
|
|
315
|
+
bottomValue: btmVal,
|
|
316
|
+
topColor: topC.color,
|
|
317
|
+
topOpacity: topC.opacity,
|
|
318
|
+
bottomColor: btmC.color,
|
|
319
|
+
bottomOpacity: btmC.opacity,
|
|
320
|
+
topIsNa,
|
|
321
|
+
btmIsNa,
|
|
231
322
|
};
|
|
232
323
|
} else {
|
|
233
|
-
|
|
234
|
-
gradientColors[i] = {
|
|
235
|
-
topColor: 'rgba(128,128,128,0.2)',
|
|
236
|
-
topOpacity: 0.2,
|
|
237
|
-
bottomColor: 'rgba(128,128,128,0.2)',
|
|
238
|
-
bottomOpacity: 0.2,
|
|
239
|
-
};
|
|
324
|
+
gradientBars[i] = null;
|
|
240
325
|
}
|
|
241
326
|
}
|
|
242
327
|
|
|
243
|
-
//
|
|
244
|
-
const
|
|
328
|
+
// Build fill data using PLOT values as polygon boundaries
|
|
329
|
+
const fillData: any[] = [];
|
|
245
330
|
for (let i = 0; i < totalDataLength; i++) {
|
|
246
331
|
const y1 = plot1Data[i];
|
|
247
332
|
const y2 = plot2Data[i];
|
|
248
333
|
const prevY1 = i > 0 ? plot1Data[i - 1] : null;
|
|
249
334
|
const prevY2 = i > 0 ? plot2Data[i - 1] : null;
|
|
250
|
-
|
|
335
|
+
fillData.push([i, y1, y2, prevY1, prevY2]);
|
|
251
336
|
}
|
|
252
337
|
|
|
253
338
|
return {
|
|
@@ -269,51 +354,95 @@ export class FillRenderer implements SeriesRenderer {
|
|
|
269
354
|
const prevY2 = api.value(4);
|
|
270
355
|
|
|
271
356
|
if (
|
|
272
|
-
y1
|
|
357
|
+
y1 == null || y2 == null || prevY1 == null || prevY2 == null ||
|
|
273
358
|
isNaN(y1) || isNaN(y2) || isNaN(prevY1) || isNaN(prevY2)
|
|
274
359
|
) {
|
|
275
360
|
return null;
|
|
276
361
|
}
|
|
277
362
|
|
|
363
|
+
const gb = gradientBars[index];
|
|
364
|
+
if (!gb) return null;
|
|
365
|
+
|
|
366
|
+
const gradRange = gb.topValue - gb.bottomValue;
|
|
367
|
+
const hasNaSide = gb.topIsNa || gb.btmIsNa;
|
|
368
|
+
|
|
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));
|
|
375
|
+
|
|
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
|
+
};
|
|
384
|
+
|
|
385
|
+
// Build polygon between the two plot lines
|
|
278
386
|
const p1Prev = api.coord([index - 1, prevY1]);
|
|
279
387
|
const p1Curr = api.coord([index, y1]);
|
|
280
388
|
const p2Curr = api.coord([index, y2]);
|
|
281
389
|
const p2Prev = api.coord([index - 1, prevY2]);
|
|
282
390
|
|
|
283
|
-
//
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
// Skip fully transparent gradient fills
|
|
288
|
-
if (gc.topOpacity < 0.01 && gc.bottomOpacity < 0.01) return null;
|
|
289
|
-
|
|
290
|
-
// Convert colors to rgba strings with their opacities
|
|
291
|
-
const topRgba = ColorUtils.toRgba(gc.topColor, gc.topOpacity);
|
|
292
|
-
const bottomRgba = ColorUtils.toRgba(gc.bottomColor, gc.bottomOpacity);
|
|
293
|
-
|
|
294
|
-
// Determine if plot1 is above plot2 (in value space, higher value = higher on chart)
|
|
295
|
-
// We want top_color at the higher value, bottom_color at the lower value
|
|
296
|
-
const plot1IsAbove = y1 >= y2;
|
|
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);
|
|
297
394
|
|
|
298
|
-
|
|
395
|
+
const polygon: any = {
|
|
299
396
|
type: 'polygon',
|
|
300
|
-
shape: {
|
|
301
|
-
points: [p1Prev, p1Curr, p2Curr, p2Prev],
|
|
302
|
-
},
|
|
397
|
+
shape: { points: [p1Prev, p1Curr, p2Curr, p2Prev] },
|
|
303
398
|
style: {
|
|
304
399
|
fill: {
|
|
305
400
|
type: 'linear',
|
|
306
|
-
x: 0, y: 0, x2: 0, y2: 1,
|
|
401
|
+
x: 0, y: 0, x2: 0, y2: 1,
|
|
307
402
|
colorStops: [
|
|
308
|
-
{ offset: 0, color:
|
|
309
|
-
{ offset: 1, color:
|
|
403
|
+
{ offset: 0, color: colorAtY(polyTop) },
|
|
404
|
+
{ offset: 1, color: colorAtY(polyBot) },
|
|
310
405
|
],
|
|
311
406
|
},
|
|
312
407
|
},
|
|
313
408
|
silent: true,
|
|
314
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;
|
|
315
444
|
},
|
|
316
|
-
data:
|
|
445
|
+
data: fillData,
|
|
317
446
|
silent: true,
|
|
318
447
|
};
|
|
319
448
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { SeriesRenderer, RenderContext } from './SeriesRenderer';
|
|
1
|
+
import { SeriesRenderer, RenderContext, resolveXCoord } from './SeriesRenderer';
|
|
2
2
|
import { ShapeUtils } from '../../utils/ShapeUtils';
|
|
3
3
|
|
|
4
4
|
export class LabelRenderer implements SeriesRenderer {
|
|
5
5
|
render(context: RenderContext): any {
|
|
6
|
-
const { seriesName, xAxisIndex, yAxisIndex, dataArray, candlestickData, dataIndexOffset } = context;
|
|
6
|
+
const { seriesName, xAxisIndex, yAxisIndex, dataArray, candlestickData, dataIndexOffset, timeToIndex, marketData } = context;
|
|
7
7
|
const offset = dataIndexOffset || 0;
|
|
8
8
|
|
|
9
9
|
// Collect all non-null, non-deleted label objects from the sparse dataArray.
|
|
@@ -42,7 +42,8 @@ export class LabelRenderer implements SeriesRenderer {
|
|
|
42
42
|
const shape = this.styleToShape(styleRaw);
|
|
43
43
|
|
|
44
44
|
// Determine X position using label's own x coordinate
|
|
45
|
-
const xPos = (lbl.
|
|
45
|
+
const xPos = resolveXCoord(lbl.x, lbl.xloc, offset, timeToIndex, marketData);
|
|
46
|
+
if (isNaN(xPos)) return null;
|
|
46
47
|
|
|
47
48
|
// Determine Y value based on yloc
|
|
48
49
|
let yValue = lbl.y;
|
|
@@ -126,6 +127,18 @@ export class LabelRenderer implements SeriesRenderer {
|
|
|
126
127
|
labelTextOffset = [0, totalHeight * pointerRatio * 0.5];
|
|
127
128
|
}
|
|
128
129
|
}
|
|
130
|
+
} else if (shape === 'labelcenter') {
|
|
131
|
+
// label_center: no pointer, centered at exact coordinate.
|
|
132
|
+
// Size the bubble body to fit text but apply NO offset.
|
|
133
|
+
const lines = text.split('\n');
|
|
134
|
+
const longestLine = lines.reduce((a: string, b: string) => a.length > b.length ? a : b, '');
|
|
135
|
+
const textWidth = longestLine.length * fontSize * 0.65;
|
|
136
|
+
const minWidth = fontSize * 2.5;
|
|
137
|
+
const bubbleWidth = Math.max(minWidth, textWidth + fontSize * 1.6);
|
|
138
|
+
const lineHeight = fontSize * 1.4;
|
|
139
|
+
const bubbleHeight = Math.max(fontSize * 2.8, lines.length * lineHeight + fontSize * 1.2);
|
|
140
|
+
finalSize = [bubbleWidth, bubbleHeight];
|
|
141
|
+
// No symbolOffset — center exactly at the coordinate
|
|
129
142
|
} else if (shape === 'none') {
|
|
130
143
|
finalSize = 0;
|
|
131
144
|
} else {
|
|
@@ -228,7 +241,7 @@ export class LabelRenderer implements SeriesRenderer {
|
|
|
228
241
|
case 'label_upper_right':
|
|
229
242
|
return 'labelup';
|
|
230
243
|
case 'label_center':
|
|
231
|
-
return '
|
|
244
|
+
return 'labelcenter';
|
|
232
245
|
case 'circle':
|
|
233
246
|
return 'circle';
|
|
234
247
|
case 'square':
|
|
@@ -289,16 +302,16 @@ export class LabelRenderer implements SeriesRenderer {
|
|
|
289
302
|
case 'tiny':
|
|
290
303
|
return 8;
|
|
291
304
|
case 'small':
|
|
292
|
-
return
|
|
305
|
+
return 11;
|
|
293
306
|
case 'normal':
|
|
294
307
|
case 'auto':
|
|
295
|
-
return
|
|
308
|
+
return 14;
|
|
296
309
|
case 'large':
|
|
297
|
-
return
|
|
310
|
+
return 20;
|
|
298
311
|
case 'huge':
|
|
299
|
-
return
|
|
312
|
+
return 36;
|
|
300
313
|
default:
|
|
301
|
-
return
|
|
314
|
+
return 14;
|
|
302
315
|
}
|
|
303
316
|
}
|
|
304
317
|
}
|
|
@@ -1,44 +1,44 @@
|
|
|
1
|
-
import { SeriesRenderer, RenderContext } from './SeriesRenderer';
|
|
2
|
-
|
|
3
|
-
export class LineRenderer 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: 'custom',
|
|
11
|
-
xAxisIndex: xAxisIndex,
|
|
12
|
-
yAxisIndex: yAxisIndex,
|
|
13
|
-
renderItem: (params: any, api: any) => {
|
|
14
|
-
const index = params.dataIndex;
|
|
15
|
-
if (index === 0) return; // Need at least two points for a line segment
|
|
16
|
-
|
|
17
|
-
const y2 = api.value(1);
|
|
18
|
-
const y1 = api.value(2); // We'll store prevValue in the data
|
|
19
|
-
|
|
20
|
-
if (y2 === null || isNaN(y2) || y1 === null || isNaN(y1)) return;
|
|
21
|
-
|
|
22
|
-
const p1 = api.coord([index - 1, y1]);
|
|
23
|
-
const p2 = api.coord([index, y2]);
|
|
24
|
-
|
|
25
|
-
return {
|
|
26
|
-
type: 'line',
|
|
27
|
-
shape: {
|
|
28
|
-
x1: p1[0],
|
|
29
|
-
y1: p1[1],
|
|
30
|
-
x2: p2[0],
|
|
31
|
-
y2: p2[1],
|
|
32
|
-
},
|
|
33
|
-
style: {
|
|
34
|
-
stroke: colorArray[index] || plotOptions.color || defaultColor,
|
|
35
|
-
lineWidth: plotOptions.linewidth || 1,
|
|
36
|
-
},
|
|
37
|
-
silent: true,
|
|
38
|
-
};
|
|
39
|
-
},
|
|
40
|
-
// Data format: [index, value, prevValue]
|
|
41
|
-
data: dataArray.map((val, i) => [i, val, i > 0 ? dataArray[i - 1] : null]),
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
}
|
|
1
|
+
import { SeriesRenderer, RenderContext } from './SeriesRenderer';
|
|
2
|
+
|
|
3
|
+
export class LineRenderer 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: 'custom',
|
|
11
|
+
xAxisIndex: xAxisIndex,
|
|
12
|
+
yAxisIndex: yAxisIndex,
|
|
13
|
+
renderItem: (params: any, api: any) => {
|
|
14
|
+
const index = params.dataIndex;
|
|
15
|
+
if (index === 0) return; // Need at least two points for a line segment
|
|
16
|
+
|
|
17
|
+
const y2 = api.value(1);
|
|
18
|
+
const y1 = api.value(2); // We'll store prevValue in the data
|
|
19
|
+
|
|
20
|
+
if (y2 === null || isNaN(y2) || y1 === null || isNaN(y1)) return;
|
|
21
|
+
|
|
22
|
+
const p1 = api.coord([index - 1, y1]);
|
|
23
|
+
const p2 = api.coord([index, y2]);
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
type: 'line',
|
|
27
|
+
shape: {
|
|
28
|
+
x1: p1[0],
|
|
29
|
+
y1: p1[1],
|
|
30
|
+
x2: p2[0],
|
|
31
|
+
y2: p2[1],
|
|
32
|
+
},
|
|
33
|
+
style: {
|
|
34
|
+
stroke: colorArray[index] || plotOptions.color || defaultColor,
|
|
35
|
+
lineWidth: plotOptions.linewidth || 1,
|
|
36
|
+
},
|
|
37
|
+
silent: true,
|
|
38
|
+
};
|
|
39
|
+
},
|
|
40
|
+
// Data format: [index, value, prevValue]
|
|
41
|
+
data: dataArray.map((val, i) => [i, val, i > 0 ? dataArray[i - 1] : null]),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { SeriesRenderer, RenderContext } from './SeriesRenderer';
|
|
1
|
+
import { SeriesRenderer, RenderContext, resolveXCoord } from './SeriesRenderer';
|
|
2
2
|
import { ColorUtils } from '../../utils/ColorUtils';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -9,7 +9,7 @@ import { ColorUtils } from '../../utils/ColorUtils';
|
|
|
9
9
|
*/
|
|
10
10
|
export class LinefillRenderer implements SeriesRenderer {
|
|
11
11
|
render(context: RenderContext): any {
|
|
12
|
-
const { seriesName, xAxisIndex, yAxisIndex, dataArray, dataIndexOffset } = context;
|
|
12
|
+
const { seriesName, xAxisIndex, yAxisIndex, dataArray, dataIndexOffset, timeToIndex, marketData } = context;
|
|
13
13
|
const offset = dataIndexOffset || 0;
|
|
14
14
|
|
|
15
15
|
// Collect all non-deleted linefill objects from the sparse dataArray.
|
|
@@ -60,13 +60,16 @@ export class LinefillRenderer implements SeriesRenderer {
|
|
|
60
60
|
const line2 = lf.line2;
|
|
61
61
|
if (!line1 || !line2 || line1._deleted || line2._deleted) continue;
|
|
62
62
|
|
|
63
|
-
const
|
|
64
|
-
const
|
|
63
|
+
const l1x1 = resolveXCoord(line1.x1, line1.xloc, offset, timeToIndex, marketData);
|
|
64
|
+
const l1x2 = resolveXCoord(line1.x2, line1.xloc, offset, timeToIndex, marketData);
|
|
65
|
+
const l2x1 = resolveXCoord(line2.x1, line2.xloc, offset, timeToIndex, marketData);
|
|
66
|
+
const l2x2 = resolveXCoord(line2.x2, line2.xloc, offset, timeToIndex, marketData);
|
|
67
|
+
if (isNaN(l1x1) || isNaN(l1x2) || isNaN(l2x1) || isNaN(l2x2)) continue;
|
|
65
68
|
|
|
66
|
-
let p1Start = api.coord([
|
|
67
|
-
let p1End = api.coord([
|
|
68
|
-
let p2Start = api.coord([
|
|
69
|
-
let p2End = api.coord([
|
|
69
|
+
let p1Start = api.coord([l1x1, line1.y1]);
|
|
70
|
+
let p1End = api.coord([l1x2, line1.y2]);
|
|
71
|
+
let p2Start = api.coord([l2x1, line2.y1]);
|
|
72
|
+
let p2End = api.coord([l2x2, line2.y2]);
|
|
70
73
|
|
|
71
74
|
// Handle line extensions
|
|
72
75
|
const extend1 = line1.extend || 'none';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { SeriesRenderer, RenderContext } from './SeriesRenderer';
|
|
1
|
+
import { SeriesRenderer, RenderContext, resolveXCoord } from './SeriesRenderer';
|
|
2
2
|
import { ColorUtils } from '../../utils/ColorUtils';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -10,7 +10,7 @@ import { ColorUtils } from '../../utils/ColorUtils';
|
|
|
10
10
|
*/
|
|
11
11
|
export class PolylineRenderer implements SeriesRenderer {
|
|
12
12
|
render(context: RenderContext): any {
|
|
13
|
-
const { seriesName, xAxisIndex, yAxisIndex, dataArray, dataIndexOffset } = context;
|
|
13
|
+
const { seriesName, xAxisIndex, yAxisIndex, dataArray, dataIndexOffset, timeToIndex, marketData } = context;
|
|
14
14
|
const offset = dataIndexOffset || 0;
|
|
15
15
|
|
|
16
16
|
// Collect all non-deleted polyline objects from the sparse dataArray.
|
|
@@ -57,15 +57,22 @@ export class PolylineRenderer implements SeriesRenderer {
|
|
|
57
57
|
if (!points || points.length < 2) continue;
|
|
58
58
|
|
|
59
59
|
const useBi = pl.xloc === 'bi' || pl.xloc === 'bar_index';
|
|
60
|
-
const xOff = useBi ? offset : 0;
|
|
61
60
|
|
|
62
61
|
// Convert chart.point objects to pixel coordinates
|
|
63
62
|
const pixelPoints: number[][] = [];
|
|
63
|
+
let skipPoly = false;
|
|
64
64
|
for (const pt of points) {
|
|
65
|
-
|
|
65
|
+
let x: number;
|
|
66
|
+
if (useBi) {
|
|
67
|
+
x = (pt.index ?? 0) + offset;
|
|
68
|
+
} else {
|
|
69
|
+
x = resolveXCoord(pt.time ?? 0, 'bt', offset, timeToIndex, marketData);
|
|
70
|
+
if (isNaN(x)) { skipPoly = true; break; }
|
|
71
|
+
}
|
|
66
72
|
const y = pt.price ?? 0;
|
|
67
73
|
pixelPoints.push(api.coord([x, y]));
|
|
68
74
|
}
|
|
75
|
+
if (skipPoly) continue;
|
|
69
76
|
|
|
70
77
|
if (pixelPoints.length < 2) continue;
|
|
71
78
|
|
|
@@ -14,8 +14,86 @@ export interface RenderContext {
|
|
|
14
14
|
plotName?: string;
|
|
15
15
|
indicator?: any; // Reference to parent indicator object if needed
|
|
16
16
|
dataIndexOffset?: number; // Padding offset for converting bar_index to ECharts index
|
|
17
|
+
timeToIndex?: Map<number, number>; // Map timestamp → real data index (for xloc.bar_time)
|
|
18
|
+
marketData?: OHLCV[]; // Raw market data (for interpolating future timestamps)
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
export interface SeriesRenderer {
|
|
20
22
|
render(context: RenderContext): any;
|
|
21
23
|
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Convert an x-coordinate from a drawing object to an ECharts padded bar index.
|
|
27
|
+
* Handles both xloc modes:
|
|
28
|
+
* - 'bar_index' / 'bi': x is already a bar index, just add padding offset
|
|
29
|
+
* - 'bar_time' / 'bt': x is a timestamp, look up in timeToIndex or interpolate
|
|
30
|
+
*
|
|
31
|
+
* For future timestamps (beyond the last candle), extrapolates position using
|
|
32
|
+
* the average bar duration from market data.
|
|
33
|
+
*
|
|
34
|
+
* Returns NaN if the coordinate cannot be resolved.
|
|
35
|
+
*/
|
|
36
|
+
export function resolveXCoord(
|
|
37
|
+
x: number,
|
|
38
|
+
xloc: string | undefined,
|
|
39
|
+
offset: number,
|
|
40
|
+
timeToIndex?: Map<number, number>,
|
|
41
|
+
marketData?: OHLCV[],
|
|
42
|
+
): number {
|
|
43
|
+
if (!xloc || xloc === 'bar_index' || xloc === 'bi') {
|
|
44
|
+
return x + offset;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// xloc is 'bar_time' / 'bt' — x is a timestamp
|
|
48
|
+
if (timeToIndex) {
|
|
49
|
+
const idx = timeToIndex.get(x);
|
|
50
|
+
if (idx !== undefined) {
|
|
51
|
+
return idx + offset;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Timestamp not in the map — interpolate (likely a future timestamp)
|
|
56
|
+
if (marketData && marketData.length >= 2) {
|
|
57
|
+
const lastTime = marketData[marketData.length - 1].time;
|
|
58
|
+
const lastIndex = marketData.length - 1;
|
|
59
|
+
|
|
60
|
+
if (x > lastTime) {
|
|
61
|
+
// Future timestamp: extrapolate using average bar duration
|
|
62
|
+
// Use the last bar's interval as representative
|
|
63
|
+
const prevTime = marketData[marketData.length - 2].time;
|
|
64
|
+
const barDuration = lastTime - prevTime;
|
|
65
|
+
if (barDuration > 0) {
|
|
66
|
+
const barsAhead = (x - lastTime) / barDuration;
|
|
67
|
+
return lastIndex + barsAhead + offset;
|
|
68
|
+
}
|
|
69
|
+
} else if (x < marketData[0].time) {
|
|
70
|
+
// Past timestamp before data start: extrapolate backwards
|
|
71
|
+
const firstTime = marketData[0].time;
|
|
72
|
+
const secondTime = marketData[1].time;
|
|
73
|
+
const barDuration = secondTime - firstTime;
|
|
74
|
+
if (barDuration > 0) {
|
|
75
|
+
const barsBehind = (firstTime - x) / barDuration;
|
|
76
|
+
return 0 - barsBehind + offset;
|
|
77
|
+
}
|
|
78
|
+
} else {
|
|
79
|
+
// Timestamp within data range but not an exact match — find nearest
|
|
80
|
+
// Binary search for the closest bar
|
|
81
|
+
let lo = 0, hi = marketData.length - 1;
|
|
82
|
+
while (lo < hi) {
|
|
83
|
+
const mid = (lo + hi) >> 1;
|
|
84
|
+
if (marketData[mid].time < x) lo = mid + 1;
|
|
85
|
+
else hi = mid;
|
|
86
|
+
}
|
|
87
|
+
// Interpolate between lo-1 and lo
|
|
88
|
+
if (lo > 0) {
|
|
89
|
+
const t0 = marketData[lo - 1].time;
|
|
90
|
+
const t1 = marketData[lo].time;
|
|
91
|
+
const frac = (x - t0) / (t1 - t0);
|
|
92
|
+
return (lo - 1) + frac + offset;
|
|
93
|
+
}
|
|
94
|
+
return lo + offset;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return NaN;
|
|
99
|
+
}
|