@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.
@@ -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,8 +247,15 @@ export class FillRenderer implements SeriesRenderer {
201
247
 
202
248
  /**
203
249
  * Render a gradient fill between two plots.
204
- * Uses a vertical linear gradient from top_color (at the upper boundary)
205
- * to bottom_color (at the lower boundary) for each polygon segment.
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 color arrays from optionsArray
218
- // Each entry in optionsArray has: { top_value, bottom_value, top_color, bottom_color }
219
- const gradientColors: { topColor: string; topOpacity: number; bottomColor: string; bottomOpacity: number }[] = [];
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 top = ColorUtils.parseColor(opts.top_color);
225
- const bottom = ColorUtils.parseColor(opts.bottom_color);
226
- gradientColors[i] = {
227
- topColor: top.color,
228
- topOpacity: top.opacity,
229
- bottomColor: bottom.color,
230
- bottomOpacity: bottom.opacity,
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
- // Fallback: use a default semi-transparent fill
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
- // Create fill data with previous values
244
- const fillDataWithPrev: any[] = [];
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
- fillDataWithPrev.push([i, y1, y2, prevY1, prevY2]);
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 === null || y2 === null || prevY1 === null || prevY2 === null ||
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
- // Get gradient colors for this bar
284
- const gc = gradientColors[index] || gradientColors[index - 1];
285
- if (!gc) return null;
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
- return {
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, // vertical gradient
401
+ x: 0, y: 0, x2: 0, y2: 1,
307
402
  colorStops: [
308
- { offset: 0, color: plot1IsAbove ? topRgba : bottomRgba },
309
- { offset: 1, color: plot1IsAbove ? bottomRgba : topRgba },
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: fillDataWithPrev,
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.xloc === 'bar_index' || lbl.xloc === 'bi') ? (lbl.x + offset) : lbl.x;
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 'labeldown';
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 9;
305
+ return 11;
293
306
  case 'normal':
294
307
  case 'auto':
295
- return 10;
308
+ return 14;
296
309
  case 'large':
297
- return 12;
310
+ return 20;
298
311
  case 'huge':
299
- return 14;
312
+ return 36;
300
313
  default:
301
- return 10;
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 xOff1 = (line1.xloc === 'bar_index' || line1.xloc === 'bi') ? offset : 0;
64
- const xOff2 = (line2.xloc === 'bar_index' || line2.xloc === 'bi') ? offset : 0;
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([line1.x1 + xOff1, line1.y1]);
67
- let p1End = api.coord([line1.x2 + xOff1, line1.y2]);
68
- let p2Start = api.coord([line2.x1 + xOff2, line2.y1]);
69
- let p2End = api.coord([line2.x2 + xOff2, line2.y2]);
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
- const x = useBi ? (pt.index ?? 0) + xOff : (pt.time ?? 0);
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
+ }