@qfo/qfchart 0.6.4 → 0.6.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/README.md +1 -0
- package/dist/qfchart.min.browser.js +15 -15
- package/dist/qfchart.min.es.js +15 -15
- package/package.json +1 -1
- package/src/QFChart.ts +9 -6
- package/src/components/LayoutManager.ts +53 -49
- package/src/components/SeriesBuilder.ts +250 -941
- package/src/components/SeriesRendererFactory.ts +36 -0
- package/src/components/renderers/BackgroundRenderer.ts +47 -0
- package/src/components/renderers/FillRenderer.ts +99 -0
- package/src/components/renderers/HistogramRenderer.ts +20 -0
- package/src/components/renderers/LineRenderer.ts +44 -0
- package/src/components/renderers/OHLCBarRenderer.ts +161 -0
- package/src/components/renderers/ScatterRenderer.ts +54 -0
- package/src/components/renderers/SeriesRenderer.ts +20 -0
- package/src/components/renderers/ShapeRenderer.ts +121 -0
- package/src/components/renderers/StepRenderer.ts +39 -0
- package/src/types.ts +205 -205
- package/src/utils/AxisUtils.ts +63 -0
- package/src/utils/ColorUtils.ts +32 -0
- package/src/utils/ShapeUtils.ts +140 -0
- /package/src/{Utils.ts → utils/CanvasUtils.ts} +0 -0
|
@@ -1,941 +1,250 @@
|
|
|
1
|
-
import { OHLCV, Indicator as IndicatorType, QFChartOptions, IndicatorPlot, IndicatorStyle } from '../types';
|
|
2
|
-
import { PaneConfiguration } from './LayoutManager';
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
return { position: 'inside', distance: 0 };
|
|
252
|
-
}
|
|
253
|
-
// For other shapes, text above by default
|
|
254
|
-
return { position: 'top', distance: 5 };
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
public static buildIndicatorSeries(
|
|
259
|
-
indicators: Map<string, IndicatorType>,
|
|
260
|
-
timeToIndex: Map<number, number>,
|
|
261
|
-
paneLayout: PaneConfiguration[],
|
|
262
|
-
totalDataLength: number,
|
|
263
|
-
dataIndexOffset: number = 0,
|
|
264
|
-
candlestickData?: OHLCV[], // Add candlestick data to access High/Low for positioning
|
|
265
|
-
overlayYAxisMap?: Map<string, number>, // Map of overlay indicator IDs to their Y-axis indices
|
|
266
|
-
separatePaneYAxisOffset: number = 1 // Offset for separate pane Y-axes (accounts for overlay axes)
|
|
267
|
-
): { series: any[]; barColors: (string | null)[] } {
|
|
268
|
-
const series: any[] = [];
|
|
269
|
-
const barColors: (string | null)[] = new Array(totalDataLength).fill(null);
|
|
270
|
-
|
|
271
|
-
// Store plot data arrays for fill plots to reference
|
|
272
|
-
const plotDataArrays = new Map<string, number[]>();
|
|
273
|
-
|
|
274
|
-
indicators.forEach((indicator, id) => {
|
|
275
|
-
if (indicator.collapsed) return; // Skip if collapsed
|
|
276
|
-
|
|
277
|
-
// Sort plots so that 'fill' plots are processed last
|
|
278
|
-
// This ensures that the plots they reference (plot1, plot2) have already been processed and their data stored
|
|
279
|
-
const sortedPlots = Object.keys(indicator.plots).sort((a, b) => {
|
|
280
|
-
const plotA = indicator.plots[a];
|
|
281
|
-
const plotB = indicator.plots[b];
|
|
282
|
-
const isFillA = plotA.options.style === 'fill';
|
|
283
|
-
const isFillB = plotB.options.style === 'fill';
|
|
284
|
-
if (isFillA && !isFillB) return 1;
|
|
285
|
-
if (!isFillA && isFillB) return -1;
|
|
286
|
-
return 0;
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
sortedPlots.forEach((plotName) => {
|
|
290
|
-
const plot = indicator.plots[plotName];
|
|
291
|
-
const seriesName = `${id}::${plotName}`;
|
|
292
|
-
|
|
293
|
-
// Find axis index for THIS SPECIFIC PLOT
|
|
294
|
-
let xAxisIndex = 0;
|
|
295
|
-
let yAxisIndex = 0;
|
|
296
|
-
|
|
297
|
-
// Check plot-level overlay setting (overrides indicator-level setting)
|
|
298
|
-
const plotOverlay = plot.options.overlay;
|
|
299
|
-
const isPlotOverlay = plotOverlay !== undefined ? plotOverlay : indicator.paneIndex === 0;
|
|
300
|
-
|
|
301
|
-
if (isPlotOverlay) {
|
|
302
|
-
// Plot should be on main chart (overlay)
|
|
303
|
-
xAxisIndex = 0;
|
|
304
|
-
if (overlayYAxisMap && overlayYAxisMap.has(seriesName)) {
|
|
305
|
-
// This specific plot has its own Y-axis (incompatible with price range)
|
|
306
|
-
yAxisIndex = overlayYAxisMap.get(seriesName)!;
|
|
307
|
-
} else {
|
|
308
|
-
// Shares main Y-axis with candlesticks
|
|
309
|
-
yAxisIndex = 0;
|
|
310
|
-
}
|
|
311
|
-
} else {
|
|
312
|
-
// Plot should be in indicator's separate pane
|
|
313
|
-
const confIndex = paneLayout.findIndex((p) => p.index === indicator.paneIndex);
|
|
314
|
-
if (confIndex !== -1) {
|
|
315
|
-
xAxisIndex = confIndex + 1;
|
|
316
|
-
yAxisIndex = separatePaneYAxisOffset + confIndex;
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
// Prepare data arrays
|
|
321
|
-
// For 'fill' style, we don't use plot.data directly in the same way, but we initialize generic arrays
|
|
322
|
-
const dataArray = new Array(totalDataLength).fill(null);
|
|
323
|
-
const colorArray = new Array(totalDataLength).fill(null);
|
|
324
|
-
const optionsArray = new Array(totalDataLength).fill(null); // Store per-point options
|
|
325
|
-
|
|
326
|
-
plot.data?.forEach((point) => {
|
|
327
|
-
const index = timeToIndex.get(point.time);
|
|
328
|
-
if (index !== undefined) {
|
|
329
|
-
const plotOffset = point.options?.offset ?? plot.options.offset ?? 0;
|
|
330
|
-
const offsetIndex = index + dataIndexOffset + plotOffset;
|
|
331
|
-
|
|
332
|
-
if (offsetIndex >= 0 && offsetIndex < totalDataLength) {
|
|
333
|
-
let value = point.value;
|
|
334
|
-
const pointColor = point.options?.color;
|
|
335
|
-
|
|
336
|
-
// TradingView compatibility: if color is 'na' (NaN, null, or "na"), break the line
|
|
337
|
-
const isNaColor =
|
|
338
|
-
pointColor === null ||
|
|
339
|
-
pointColor === 'na' ||
|
|
340
|
-
pointColor === 'NaN' ||
|
|
341
|
-
(typeof pointColor === 'number' && isNaN(pointColor));
|
|
342
|
-
|
|
343
|
-
if (isNaColor) {
|
|
344
|
-
value = null;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
dataArray[offsetIndex] = value;
|
|
348
|
-
colorArray[offsetIndex] = pointColor || plot.options.color || SeriesBuilder.DEFAULT_COLOR;
|
|
349
|
-
optionsArray[offsetIndex] = point.options || {};
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
});
|
|
353
|
-
|
|
354
|
-
// Store data array for fill plots to reference
|
|
355
|
-
// Only store for non-fill plots as fill plots don't produce data to be referenced by other fills (usually)
|
|
356
|
-
plotDataArrays.set(`${id}::${plotName}`, dataArray);
|
|
357
|
-
|
|
358
|
-
if (plot.options?.style?.startsWith('style_')) {
|
|
359
|
-
plot.options.style = plot.options.style.replace('style_', '') as IndicatorStyle;
|
|
360
|
-
}
|
|
361
|
-
switch (plot.options.style) {
|
|
362
|
-
case 'histogram':
|
|
363
|
-
case 'columns':
|
|
364
|
-
series.push({
|
|
365
|
-
name: seriesName,
|
|
366
|
-
type: 'bar',
|
|
367
|
-
xAxisIndex: xAxisIndex,
|
|
368
|
-
yAxisIndex: yAxisIndex,
|
|
369
|
-
data: dataArray.map((val, i) => ({
|
|
370
|
-
value: val,
|
|
371
|
-
itemStyle: colorArray[i] ? { color: colorArray[i] } : undefined,
|
|
372
|
-
})),
|
|
373
|
-
itemStyle: { color: plot.options.color || SeriesBuilder.DEFAULT_COLOR },
|
|
374
|
-
});
|
|
375
|
-
break;
|
|
376
|
-
|
|
377
|
-
case 'circles':
|
|
378
|
-
case 'cross':
|
|
379
|
-
// Scatter
|
|
380
|
-
const scatterData = dataArray
|
|
381
|
-
.map((val, i) => {
|
|
382
|
-
if (val === null) return null;
|
|
383
|
-
const pointColor = colorArray[i] || plot.options.color || SeriesBuilder.DEFAULT_COLOR;
|
|
384
|
-
const item: any = {
|
|
385
|
-
value: [i, val],
|
|
386
|
-
itemStyle: { color: pointColor },
|
|
387
|
-
};
|
|
388
|
-
|
|
389
|
-
if (plot.options.style === 'cross') {
|
|
390
|
-
item.symbol = `image://${textToBase64Image('+', pointColor, '24px')}`;
|
|
391
|
-
item.symbolSize = 16;
|
|
392
|
-
} else {
|
|
393
|
-
item.symbol = 'circle';
|
|
394
|
-
item.symbolSize = 6;
|
|
395
|
-
}
|
|
396
|
-
return item;
|
|
397
|
-
})
|
|
398
|
-
.filter((item) => item !== null);
|
|
399
|
-
|
|
400
|
-
series.push({
|
|
401
|
-
name: seriesName,
|
|
402
|
-
type: 'scatter',
|
|
403
|
-
xAxisIndex: xAxisIndex,
|
|
404
|
-
yAxisIndex: yAxisIndex,
|
|
405
|
-
data: scatterData,
|
|
406
|
-
});
|
|
407
|
-
break;
|
|
408
|
-
|
|
409
|
-
case 'bar':
|
|
410
|
-
case 'candle':
|
|
411
|
-
// OHLC Bar/Candle rendering
|
|
412
|
-
const ohlcData = dataArray
|
|
413
|
-
.map((val, i) => {
|
|
414
|
-
if (val === null || !Array.isArray(val) || val.length !== 4) return null;
|
|
415
|
-
|
|
416
|
-
const [open, high, low, close] = val;
|
|
417
|
-
const pointOpts = optionsArray[i] || {};
|
|
418
|
-
const color = pointOpts.color || colorArray[i] || plot.options.color || SeriesBuilder.DEFAULT_COLOR;
|
|
419
|
-
const wickColor = pointOpts.wickcolor || plot.options.wickcolor || color;
|
|
420
|
-
const borderColor = pointOpts.bordercolor || plot.options.bordercolor || wickColor;
|
|
421
|
-
|
|
422
|
-
// Store colors in value array at positions 5, 6, and 7 for access in renderItem
|
|
423
|
-
return [i, open, close, low, high, color, wickColor, borderColor];
|
|
424
|
-
})
|
|
425
|
-
.filter((item) => item !== null);
|
|
426
|
-
|
|
427
|
-
series.push({
|
|
428
|
-
name: seriesName,
|
|
429
|
-
type: 'custom',
|
|
430
|
-
xAxisIndex: xAxisIndex,
|
|
431
|
-
yAxisIndex: yAxisIndex,
|
|
432
|
-
renderItem: (params: any, api: any) => {
|
|
433
|
-
const xValue = api.value(0);
|
|
434
|
-
const openValue = api.value(1);
|
|
435
|
-
const closeValue = api.value(2);
|
|
436
|
-
const lowValue = api.value(3);
|
|
437
|
-
const highValue = api.value(4);
|
|
438
|
-
const color = api.value(5);
|
|
439
|
-
const wickColor = api.value(6);
|
|
440
|
-
const borderColor = api.value(7);
|
|
441
|
-
|
|
442
|
-
if (isNaN(openValue) || isNaN(closeValue) || isNaN(lowValue) || isNaN(highValue)) {
|
|
443
|
-
return null;
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
const xPos = api.coord([xValue, 0])[0];
|
|
447
|
-
const openPos = api.coord([xValue, openValue])[1];
|
|
448
|
-
const closePos = api.coord([xValue, closeValue])[1];
|
|
449
|
-
const lowPos = api.coord([xValue, lowValue])[1];
|
|
450
|
-
const highPos = api.coord([xValue, highValue])[1];
|
|
451
|
-
|
|
452
|
-
const barWidth = api.size([1, 0])[0] * 0.6;
|
|
453
|
-
|
|
454
|
-
if (plot.options.style === 'candle') {
|
|
455
|
-
// Classic candlestick rendering
|
|
456
|
-
const bodyTop = Math.min(openPos, closePos);
|
|
457
|
-
const bodyBottom = Math.max(openPos, closePos);
|
|
458
|
-
const bodyHeight = Math.abs(closePos - openPos);
|
|
459
|
-
|
|
460
|
-
return {
|
|
461
|
-
type: 'group',
|
|
462
|
-
children: [
|
|
463
|
-
// Upper wick
|
|
464
|
-
{
|
|
465
|
-
type: 'line',
|
|
466
|
-
shape: {
|
|
467
|
-
x1: xPos,
|
|
468
|
-
y1: highPos,
|
|
469
|
-
x2: xPos,
|
|
470
|
-
y2: bodyTop,
|
|
471
|
-
},
|
|
472
|
-
style: {
|
|
473
|
-
stroke: wickColor,
|
|
474
|
-
lineWidth: 1,
|
|
475
|
-
},
|
|
476
|
-
},
|
|
477
|
-
// Lower wick
|
|
478
|
-
{
|
|
479
|
-
type: 'line',
|
|
480
|
-
shape: {
|
|
481
|
-
x1: xPos,
|
|
482
|
-
y1: bodyBottom,
|
|
483
|
-
x2: xPos,
|
|
484
|
-
y2: lowPos,
|
|
485
|
-
},
|
|
486
|
-
style: {
|
|
487
|
-
stroke: wickColor,
|
|
488
|
-
lineWidth: 1,
|
|
489
|
-
},
|
|
490
|
-
},
|
|
491
|
-
// Body
|
|
492
|
-
{
|
|
493
|
-
type: 'rect',
|
|
494
|
-
shape: {
|
|
495
|
-
x: xPos - barWidth / 2,
|
|
496
|
-
y: bodyTop,
|
|
497
|
-
width: barWidth,
|
|
498
|
-
height: bodyHeight || 1, // Minimum height for doji
|
|
499
|
-
},
|
|
500
|
-
style: {
|
|
501
|
-
fill: color,
|
|
502
|
-
stroke: borderColor,
|
|
503
|
-
lineWidth: 1,
|
|
504
|
-
},
|
|
505
|
-
},
|
|
506
|
-
],
|
|
507
|
-
};
|
|
508
|
-
} else {
|
|
509
|
-
// Bar style (OHLC bar)
|
|
510
|
-
const tickWidth = barWidth * 0.5;
|
|
511
|
-
|
|
512
|
-
return {
|
|
513
|
-
type: 'group',
|
|
514
|
-
children: [
|
|
515
|
-
// Vertical line (low to high)
|
|
516
|
-
{
|
|
517
|
-
type: 'line',
|
|
518
|
-
shape: {
|
|
519
|
-
x1: xPos,
|
|
520
|
-
y1: lowPos,
|
|
521
|
-
x2: xPos,
|
|
522
|
-
y2: highPos,
|
|
523
|
-
},
|
|
524
|
-
style: {
|
|
525
|
-
stroke: color,
|
|
526
|
-
lineWidth: 1,
|
|
527
|
-
},
|
|
528
|
-
},
|
|
529
|
-
// Open tick (left)
|
|
530
|
-
{
|
|
531
|
-
type: 'line',
|
|
532
|
-
shape: {
|
|
533
|
-
x1: xPos - tickWidth,
|
|
534
|
-
y1: openPos,
|
|
535
|
-
x2: xPos,
|
|
536
|
-
y2: openPos,
|
|
537
|
-
},
|
|
538
|
-
style: {
|
|
539
|
-
stroke: color,
|
|
540
|
-
lineWidth: 1,
|
|
541
|
-
},
|
|
542
|
-
},
|
|
543
|
-
// Close tick (right)
|
|
544
|
-
{
|
|
545
|
-
type: 'line',
|
|
546
|
-
shape: {
|
|
547
|
-
x1: xPos,
|
|
548
|
-
y1: closePos,
|
|
549
|
-
x2: xPos + tickWidth,
|
|
550
|
-
y2: closePos,
|
|
551
|
-
},
|
|
552
|
-
style: {
|
|
553
|
-
stroke: color,
|
|
554
|
-
lineWidth: 1,
|
|
555
|
-
},
|
|
556
|
-
},
|
|
557
|
-
],
|
|
558
|
-
};
|
|
559
|
-
}
|
|
560
|
-
},
|
|
561
|
-
data: ohlcData,
|
|
562
|
-
});
|
|
563
|
-
break;
|
|
564
|
-
|
|
565
|
-
case 'shape':
|
|
566
|
-
const shapeData = dataArray
|
|
567
|
-
.map((val, i) => {
|
|
568
|
-
// Merge global options with per-point options to get location first
|
|
569
|
-
const pointOpts = optionsArray[i] || {};
|
|
570
|
-
const globalOpts = plot.options;
|
|
571
|
-
const location = pointOpts.location || globalOpts.location || 'absolute';
|
|
572
|
-
|
|
573
|
-
// For location="absolute", always draw the shape (ignore value)
|
|
574
|
-
// For other locations, only draw if value is truthy (TradingView behavior)
|
|
575
|
-
if (location !== 'absolute' && !val) {
|
|
576
|
-
return null;
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
// If we get here and val is null/undefined, it means location is absolute
|
|
580
|
-
// In that case, we still need a valid value for positioning
|
|
581
|
-
// Use the value if it exists, otherwise we'd need a fallback
|
|
582
|
-
// But in TradingView, absolute location still expects a value for Y position
|
|
583
|
-
if (val === null || val === undefined) {
|
|
584
|
-
return null; // Can't plot without a Y coordinate
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
const color = pointOpts.color || globalOpts.color || SeriesBuilder.DEFAULT_COLOR;
|
|
588
|
-
const shape = pointOpts.shape || globalOpts.shape || 'circle';
|
|
589
|
-
const size = pointOpts.size || globalOpts.size || 'normal';
|
|
590
|
-
const text = pointOpts.text || globalOpts.text;
|
|
591
|
-
const textColor = pointOpts.textcolor || globalOpts.textcolor || 'white';
|
|
592
|
-
|
|
593
|
-
// NEW: Get width and height
|
|
594
|
-
const width = pointOpts.width || globalOpts.width;
|
|
595
|
-
const height = pointOpts.height || globalOpts.height;
|
|
596
|
-
|
|
597
|
-
// Debug logging (remove after testing)
|
|
598
|
-
// if (width !== undefined || height !== undefined) {
|
|
599
|
-
// console.log('[Shape Debug]', { shape, width, height, pointOpts, globalOpts });
|
|
600
|
-
// }
|
|
601
|
-
|
|
602
|
-
// Positioning based on location
|
|
603
|
-
let yValue = val; // Default to absolute value
|
|
604
|
-
let symbolOffset: (string | number)[] = [0, 0];
|
|
605
|
-
|
|
606
|
-
if (location === 'abovebar') {
|
|
607
|
-
// Shape above the candle
|
|
608
|
-
if (candlestickData && candlestickData[i]) {
|
|
609
|
-
yValue = candlestickData[i].high;
|
|
610
|
-
}
|
|
611
|
-
symbolOffset = [0, '-150%']; // Shift up
|
|
612
|
-
} else if (location === 'belowbar') {
|
|
613
|
-
// Shape below the candle
|
|
614
|
-
if (candlestickData && candlestickData[i]) {
|
|
615
|
-
yValue = candlestickData[i].low;
|
|
616
|
-
}
|
|
617
|
-
symbolOffset = [0, '150%']; // Shift down
|
|
618
|
-
} else if (location === 'top') {
|
|
619
|
-
// Shape at top of chart - we need to use a very high value
|
|
620
|
-
// This would require knowing the y-axis max, which we don't have here easily
|
|
621
|
-
// For now, use a placeholder approach - might need to calculate from data
|
|
622
|
-
// Or we can use a percentage of the viewport? ECharts doesn't support that directly in scatter.
|
|
623
|
-
// Best approach: use a large multiplier of current value or track max
|
|
624
|
-
// Simplified: use coordinate system max (will need enhancement)
|
|
625
|
-
yValue = val; // For now, keep absolute - would need axis max
|
|
626
|
-
symbolOffset = [0, 0];
|
|
627
|
-
} else if (location === 'bottom') {
|
|
628
|
-
// Shape at bottom of chart
|
|
629
|
-
yValue = val; // For now, keep absolute - would need axis min
|
|
630
|
-
symbolOffset = [0, 0];
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
const symbol = SeriesBuilder.getShapeSymbol(shape);
|
|
634
|
-
const symbolSize = SeriesBuilder.getShapeSize(size, width, height);
|
|
635
|
-
const rotate = SeriesBuilder.getShapeRotation(shape);
|
|
636
|
-
|
|
637
|
-
// Debug logging (remove after testing)
|
|
638
|
-
// if (width !== undefined || height !== undefined) {
|
|
639
|
-
// console.log('[Shape Size Debug]', { symbolSize, width, height, size });
|
|
640
|
-
// }
|
|
641
|
-
|
|
642
|
-
// Special handling for labelup/down sizing - they contain text so they should be larger
|
|
643
|
-
let finalSize: number | number[] = symbolSize;
|
|
644
|
-
if (shape.includes('label')) {
|
|
645
|
-
// If custom size, scale it up for labels
|
|
646
|
-
if (Array.isArray(symbolSize)) {
|
|
647
|
-
finalSize = [symbolSize[0] * 2.5, symbolSize[1] * 2.5];
|
|
648
|
-
} else {
|
|
649
|
-
finalSize = symbolSize * 2.5;
|
|
650
|
-
}
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
// Get label configuration based on location
|
|
654
|
-
const labelConfig = SeriesBuilder.getLabelConfig(shape, location);
|
|
655
|
-
|
|
656
|
-
const item: any = {
|
|
657
|
-
value: [i, yValue],
|
|
658
|
-
symbol: symbol,
|
|
659
|
-
symbolSize: finalSize,
|
|
660
|
-
symbolRotate: rotate,
|
|
661
|
-
symbolOffset: symbolOffset,
|
|
662
|
-
itemStyle: {
|
|
663
|
-
color: color,
|
|
664
|
-
},
|
|
665
|
-
label: {
|
|
666
|
-
show: !!text,
|
|
667
|
-
position: labelConfig.position,
|
|
668
|
-
distance: labelConfig.distance,
|
|
669
|
-
formatter: text,
|
|
670
|
-
color: textColor,
|
|
671
|
-
fontSize: 10,
|
|
672
|
-
fontWeight: 'bold',
|
|
673
|
-
},
|
|
674
|
-
};
|
|
675
|
-
|
|
676
|
-
return item;
|
|
677
|
-
})
|
|
678
|
-
.filter((item) => item !== null);
|
|
679
|
-
|
|
680
|
-
series.push({
|
|
681
|
-
name: seriesName,
|
|
682
|
-
type: 'scatter',
|
|
683
|
-
xAxisIndex: xAxisIndex,
|
|
684
|
-
yAxisIndex: yAxisIndex,
|
|
685
|
-
data: shapeData,
|
|
686
|
-
});
|
|
687
|
-
break;
|
|
688
|
-
|
|
689
|
-
case 'background':
|
|
690
|
-
series.push({
|
|
691
|
-
name: seriesName,
|
|
692
|
-
type: 'custom',
|
|
693
|
-
xAxisIndex: xAxisIndex,
|
|
694
|
-
yAxisIndex: yAxisIndex,
|
|
695
|
-
z: -10,
|
|
696
|
-
renderItem: (params: any, api: any) => {
|
|
697
|
-
const xVal = api.value(0);
|
|
698
|
-
if (isNaN(xVal)) return;
|
|
699
|
-
|
|
700
|
-
const start = api.coord([xVal, 0]);
|
|
701
|
-
const size = api.size([1, 0]);
|
|
702
|
-
const width = size[0];
|
|
703
|
-
const sys = params.coordSys;
|
|
704
|
-
const x = start[0] - width / 2;
|
|
705
|
-
const barColor = colorArray[params.dataIndex];
|
|
706
|
-
const val = api.value(1);
|
|
707
|
-
|
|
708
|
-
if (!barColor || val === null || val === undefined || isNaN(val)) return;
|
|
709
|
-
|
|
710
|
-
return {
|
|
711
|
-
type: 'rect',
|
|
712
|
-
shape: {
|
|
713
|
-
x: x,
|
|
714
|
-
y: sys.y,
|
|
715
|
-
width: width,
|
|
716
|
-
height: sys.height,
|
|
717
|
-
},
|
|
718
|
-
style: {
|
|
719
|
-
fill: barColor,
|
|
720
|
-
opacity: 0.3,
|
|
721
|
-
},
|
|
722
|
-
silent: true,
|
|
723
|
-
};
|
|
724
|
-
},
|
|
725
|
-
data: dataArray.map((val, i) => [i, val]),
|
|
726
|
-
});
|
|
727
|
-
break;
|
|
728
|
-
|
|
729
|
-
case 'step':
|
|
730
|
-
series.push({
|
|
731
|
-
name: seriesName,
|
|
732
|
-
type: 'custom',
|
|
733
|
-
xAxisIndex: xAxisIndex,
|
|
734
|
-
yAxisIndex: yAxisIndex,
|
|
735
|
-
renderItem: (params: any, api: any) => {
|
|
736
|
-
const x = api.value(0);
|
|
737
|
-
const y = api.value(1);
|
|
738
|
-
if (isNaN(y) || y === null) return;
|
|
739
|
-
|
|
740
|
-
const coords = api.coord([x, y]);
|
|
741
|
-
const width = api.size([1, 0])[0];
|
|
742
|
-
|
|
743
|
-
return {
|
|
744
|
-
type: 'line',
|
|
745
|
-
shape: {
|
|
746
|
-
x1: coords[0] - width / 2,
|
|
747
|
-
y1: coords[1],
|
|
748
|
-
x2: coords[0] + width / 2,
|
|
749
|
-
y2: coords[1],
|
|
750
|
-
},
|
|
751
|
-
style: {
|
|
752
|
-
stroke: colorArray[params.dataIndex] || plot.options.color || SeriesBuilder.DEFAULT_COLOR,
|
|
753
|
-
lineWidth: plot.options.linewidth || 1,
|
|
754
|
-
},
|
|
755
|
-
silent: true,
|
|
756
|
-
};
|
|
757
|
-
},
|
|
758
|
-
data: dataArray.map((val, i) => [i, val]),
|
|
759
|
-
});
|
|
760
|
-
break;
|
|
761
|
-
|
|
762
|
-
case 'barcolor':
|
|
763
|
-
// Apply colors to main chart candlesticks
|
|
764
|
-
// Don't create a visual series, just store colors in barColors array
|
|
765
|
-
plot.data?.forEach((point) => {
|
|
766
|
-
const index = timeToIndex.get(point.time);
|
|
767
|
-
if (index !== undefined) {
|
|
768
|
-
const plotOffset = point.options?.offset ?? plot.options.offset ?? 0;
|
|
769
|
-
const offsetIndex = index + dataIndexOffset + plotOffset;
|
|
770
|
-
|
|
771
|
-
if (offsetIndex >= 0 && offsetIndex < totalDataLength) {
|
|
772
|
-
const pointColor = point.options?.color || plot.options.color || SeriesBuilder.DEFAULT_COLOR;
|
|
773
|
-
// Only apply if color is valid (not 'na')
|
|
774
|
-
const isNaColor =
|
|
775
|
-
pointColor === null ||
|
|
776
|
-
pointColor === 'na' ||
|
|
777
|
-
pointColor === 'NaN' ||
|
|
778
|
-
(typeof pointColor === 'number' && isNaN(pointColor));
|
|
779
|
-
|
|
780
|
-
if (!isNaColor && point.value !== null && point.value !== undefined) {
|
|
781
|
-
// Only apply color if value is defined (allow 0)
|
|
782
|
-
barColors[offsetIndex] = pointColor;
|
|
783
|
-
}
|
|
784
|
-
}
|
|
785
|
-
}
|
|
786
|
-
});
|
|
787
|
-
break;
|
|
788
|
-
|
|
789
|
-
case 'char':
|
|
790
|
-
// Invisible series - data only shown in tooltip/sidebar
|
|
791
|
-
series.push({
|
|
792
|
-
name: seriesName,
|
|
793
|
-
type: 'scatter',
|
|
794
|
-
xAxisIndex: xAxisIndex,
|
|
795
|
-
yAxisIndex: yAxisIndex,
|
|
796
|
-
symbolSize: 0, // Invisible
|
|
797
|
-
data: dataArray.map((val, i) => ({
|
|
798
|
-
value: [i, val],
|
|
799
|
-
itemStyle: { opacity: 0 },
|
|
800
|
-
})),
|
|
801
|
-
silent: true, // No interaction
|
|
802
|
-
});
|
|
803
|
-
break;
|
|
804
|
-
|
|
805
|
-
case 'fill':
|
|
806
|
-
// Fill plots reference other plots to fill the area between them
|
|
807
|
-
const plot1Key = plot.plot1 ? `${id}::${plot.plot1}` : null;
|
|
808
|
-
const plot2Key = plot.plot2 ? `${id}::${plot.plot2}` : null;
|
|
809
|
-
|
|
810
|
-
if (!plot1Key || !plot2Key) {
|
|
811
|
-
console.warn(`Fill plot "${plotName}" missing plot1 or plot2 reference`);
|
|
812
|
-
break;
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
const plot1Data = plotDataArrays.get(plot1Key);
|
|
816
|
-
const plot2Data = plotDataArrays.get(plot2Key);
|
|
817
|
-
|
|
818
|
-
if (!plot1Data || !plot2Data) {
|
|
819
|
-
console.warn(`Fill plot "${plotName}" references non-existent plots: ${plot.plot1}, ${plot.plot2}`);
|
|
820
|
-
break;
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
// Parse color to extract opacity
|
|
824
|
-
const { color: fillColor, opacity: fillOpacity } = SeriesBuilder.parseColor(plot.options.color || 'rgba(128, 128, 128, 0.2)');
|
|
825
|
-
|
|
826
|
-
// Create fill data with previous values for smooth polygon rendering
|
|
827
|
-
const fillDataWithPrev: any[] = [];
|
|
828
|
-
for (let i = 0; i < totalDataLength; i++) {
|
|
829
|
-
const y1 = plot1Data[i];
|
|
830
|
-
const y2 = plot2Data[i];
|
|
831
|
-
const prevY1 = i > 0 ? plot1Data[i - 1] : null;
|
|
832
|
-
const prevY2 = i > 0 ? plot2Data[i - 1] : null;
|
|
833
|
-
|
|
834
|
-
fillDataWithPrev.push([i, y1, y2, prevY1, prevY2]);
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
// Add fill series with smooth area rendering
|
|
838
|
-
series.push({
|
|
839
|
-
name: seriesName,
|
|
840
|
-
type: 'custom',
|
|
841
|
-
xAxisIndex: xAxisIndex,
|
|
842
|
-
yAxisIndex: yAxisIndex,
|
|
843
|
-
z: -5, // Render behind lines but above background
|
|
844
|
-
renderItem: (params: any, api: any) => {
|
|
845
|
-
const index = params.dataIndex;
|
|
846
|
-
|
|
847
|
-
// Skip first point (no previous to connect to)
|
|
848
|
-
if (index === 0) return null;
|
|
849
|
-
|
|
850
|
-
const y1 = api.value(1); // Current upper
|
|
851
|
-
const y2 = api.value(2); // Current lower
|
|
852
|
-
const prevY1 = api.value(3); // Previous upper
|
|
853
|
-
const prevY2 = api.value(4); // Previous lower
|
|
854
|
-
|
|
855
|
-
// Skip if any value is null/NaN
|
|
856
|
-
if (
|
|
857
|
-
y1 === null ||
|
|
858
|
-
y2 === null ||
|
|
859
|
-
prevY1 === null ||
|
|
860
|
-
prevY2 === null ||
|
|
861
|
-
isNaN(y1) ||
|
|
862
|
-
isNaN(y2) ||
|
|
863
|
-
isNaN(prevY1) ||
|
|
864
|
-
isNaN(prevY2)
|
|
865
|
-
) {
|
|
866
|
-
return null;
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
// Get pixel coordinates for all 4 points
|
|
870
|
-
const p1Prev = api.coord([index - 1, prevY1]); // Previous upper
|
|
871
|
-
const p1Curr = api.coord([index, y1]); // Current upper
|
|
872
|
-
const p2Curr = api.coord([index, y2]); // Current lower
|
|
873
|
-
const p2Prev = api.coord([index - 1, prevY2]); // Previous lower
|
|
874
|
-
|
|
875
|
-
// Create a smooth polygon connecting the segments
|
|
876
|
-
return {
|
|
877
|
-
type: 'polygon',
|
|
878
|
-
shape: {
|
|
879
|
-
points: [
|
|
880
|
-
p1Prev, // Top-left
|
|
881
|
-
p1Curr, // Top-right
|
|
882
|
-
p2Curr, // Bottom-right
|
|
883
|
-
p2Prev, // Bottom-left
|
|
884
|
-
],
|
|
885
|
-
},
|
|
886
|
-
style: {
|
|
887
|
-
fill: fillColor,
|
|
888
|
-
opacity: fillOpacity,
|
|
889
|
-
},
|
|
890
|
-
silent: true,
|
|
891
|
-
};
|
|
892
|
-
},
|
|
893
|
-
data: fillDataWithPrev,
|
|
894
|
-
});
|
|
895
|
-
break;
|
|
896
|
-
|
|
897
|
-
case 'line':
|
|
898
|
-
default:
|
|
899
|
-
series.push({
|
|
900
|
-
name: seriesName,
|
|
901
|
-
type: 'custom',
|
|
902
|
-
xAxisIndex: xAxisIndex,
|
|
903
|
-
yAxisIndex: yAxisIndex,
|
|
904
|
-
renderItem: (params: any, api: any) => {
|
|
905
|
-
const index = params.dataIndex;
|
|
906
|
-
if (index === 0) return; // Need at least two points for a line segment
|
|
907
|
-
|
|
908
|
-
const y2 = api.value(1);
|
|
909
|
-
const y1 = api.value(2); // We'll store prevValue in the data
|
|
910
|
-
|
|
911
|
-
if (y2 === null || isNaN(y2) || y1 === null || isNaN(y1)) return;
|
|
912
|
-
|
|
913
|
-
const p1 = api.coord([index - 1, y1]);
|
|
914
|
-
const p2 = api.coord([index, y2]);
|
|
915
|
-
|
|
916
|
-
return {
|
|
917
|
-
type: 'line',
|
|
918
|
-
shape: {
|
|
919
|
-
x1: p1[0],
|
|
920
|
-
y1: p1[1],
|
|
921
|
-
x2: p2[0],
|
|
922
|
-
y2: p2[1],
|
|
923
|
-
},
|
|
924
|
-
style: {
|
|
925
|
-
stroke: colorArray[index] || plot.options.color || SeriesBuilder.DEFAULT_COLOR,
|
|
926
|
-
lineWidth: plot.options.linewidth || 1,
|
|
927
|
-
},
|
|
928
|
-
silent: true,
|
|
929
|
-
};
|
|
930
|
-
},
|
|
931
|
-
// Data format: [index, value, prevValue]
|
|
932
|
-
data: dataArray.map((val, i) => [i, val, i > 0 ? dataArray[i - 1] : null]),
|
|
933
|
-
});
|
|
934
|
-
break;
|
|
935
|
-
}
|
|
936
|
-
});
|
|
937
|
-
});
|
|
938
|
-
|
|
939
|
-
return { series, barColors };
|
|
940
|
-
}
|
|
941
|
-
}
|
|
1
|
+
import { OHLCV, Indicator as IndicatorType, QFChartOptions, IndicatorPlot, IndicatorStyle } from '../types';
|
|
2
|
+
import { PaneConfiguration } from './LayoutManager';
|
|
3
|
+
import { SeriesRendererFactory } from './SeriesRendererFactory';
|
|
4
|
+
import { AxisUtils } from '../utils/AxisUtils';
|
|
5
|
+
|
|
6
|
+
export class SeriesBuilder {
|
|
7
|
+
private static readonly DEFAULT_COLOR = '#2962ff';
|
|
8
|
+
|
|
9
|
+
public static buildCandlestickSeries(marketData: OHLCV[], options: QFChartOptions, totalLength?: number): any {
|
|
10
|
+
const upColor = options.upColor || '#00da3c';
|
|
11
|
+
const downColor = options.downColor || '#ec0000';
|
|
12
|
+
|
|
13
|
+
const data = marketData.map((d) => [d.open, d.close, d.low, d.high]);
|
|
14
|
+
|
|
15
|
+
// Pad with nulls if totalLength is provided and greater than current data length
|
|
16
|
+
if (totalLength && totalLength > data.length) {
|
|
17
|
+
const padding = totalLength - data.length;
|
|
18
|
+
for (let i = 0; i < padding; i++) {
|
|
19
|
+
data.push(null as any);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Build markLine for last price if enabled
|
|
24
|
+
let markLine = undefined;
|
|
25
|
+
if (options.lastPriceLine?.visible !== false && marketData.length > 0) {
|
|
26
|
+
const lastBar = marketData[marketData.length - 1];
|
|
27
|
+
const lastClose = lastBar.close;
|
|
28
|
+
const isUp = lastBar.close >= lastBar.open;
|
|
29
|
+
// Use configured color, or dynamic color based on candle direction
|
|
30
|
+
const lineColor = options.lastPriceLine?.color || (isUp ? upColor : downColor);
|
|
31
|
+
let lineStyleType = options.lastPriceLine?.lineStyle || 'dashed';
|
|
32
|
+
|
|
33
|
+
if (lineStyleType.startsWith('linestyle_')) {
|
|
34
|
+
lineStyleType = lineStyleType.replace('linestyle_', '') as any;
|
|
35
|
+
}
|
|
36
|
+
const decimals = options.yAxisDecimalPlaces !== undefined
|
|
37
|
+
? options.yAxisDecimalPlaces
|
|
38
|
+
: AxisUtils.autoDetectDecimals(marketData);
|
|
39
|
+
|
|
40
|
+
markLine = {
|
|
41
|
+
symbol: ['none', 'none'],
|
|
42
|
+
precision: decimals, // Ensure line position is precise enough for small values
|
|
43
|
+
data: [
|
|
44
|
+
{
|
|
45
|
+
yAxis: lastClose,
|
|
46
|
+
label: {
|
|
47
|
+
show: true,
|
|
48
|
+
position: 'end', // Right side
|
|
49
|
+
formatter: (params: any) => {
|
|
50
|
+
// Respect Y-axis formatting options
|
|
51
|
+
if (options.yAxisLabelFormatter) {
|
|
52
|
+
return options.yAxisLabelFormatter(params.value);
|
|
53
|
+
}
|
|
54
|
+
return AxisUtils.formatValue(params.value, decimals);
|
|
55
|
+
},
|
|
56
|
+
color: '#fff',
|
|
57
|
+
backgroundColor: lineColor,
|
|
58
|
+
padding: [2, 4],
|
|
59
|
+
borderRadius: 2,
|
|
60
|
+
fontSize: 11,
|
|
61
|
+
fontWeight: 'bold',
|
|
62
|
+
},
|
|
63
|
+
lineStyle: {
|
|
64
|
+
color: lineColor,
|
|
65
|
+
type: lineStyleType,
|
|
66
|
+
width: 1,
|
|
67
|
+
opacity: 0.8,
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
animation: false,
|
|
72
|
+
silent: true, // Disable interaction
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
type: 'candlestick',
|
|
78
|
+
name: options.title || 'Market',
|
|
79
|
+
data: data,
|
|
80
|
+
itemStyle: {
|
|
81
|
+
color: upColor,
|
|
82
|
+
color0: downColor,
|
|
83
|
+
borderColor: upColor,
|
|
84
|
+
borderColor0: downColor,
|
|
85
|
+
},
|
|
86
|
+
markLine: markLine,
|
|
87
|
+
xAxisIndex: 0,
|
|
88
|
+
yAxisIndex: 0,
|
|
89
|
+
z: 5,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
public static buildIndicatorSeries(
|
|
94
|
+
indicators: Map<string, IndicatorType>,
|
|
95
|
+
timeToIndex: Map<number, number>,
|
|
96
|
+
paneLayout: PaneConfiguration[],
|
|
97
|
+
totalDataLength: number,
|
|
98
|
+
dataIndexOffset: number = 0,
|
|
99
|
+
candlestickData?: OHLCV[], // Add candlestick data to access High/Low for positioning
|
|
100
|
+
overlayYAxisMap?: Map<string, number>, // Map of overlay indicator IDs to their Y-axis indices
|
|
101
|
+
separatePaneYAxisOffset: number = 1 // Offset for separate pane Y-axes (accounts for overlay axes)
|
|
102
|
+
): { series: any[]; barColors: (string | null)[] } {
|
|
103
|
+
const series: any[] = [];
|
|
104
|
+
const barColors: (string | null)[] = new Array(totalDataLength).fill(null);
|
|
105
|
+
|
|
106
|
+
// Store plot data arrays for fill plots to reference
|
|
107
|
+
const plotDataArrays = new Map<string, number[]>();
|
|
108
|
+
|
|
109
|
+
indicators.forEach((indicator, id) => {
|
|
110
|
+
if (indicator.collapsed) return; // Skip if collapsed
|
|
111
|
+
|
|
112
|
+
// Sort plots so that 'fill' plots are processed last
|
|
113
|
+
// This ensures that the plots they reference (plot1, plot2) have already been processed and their data stored
|
|
114
|
+
const sortedPlots = Object.keys(indicator.plots).sort((a, b) => {
|
|
115
|
+
const plotA = indicator.plots[a];
|
|
116
|
+
const plotB = indicator.plots[b];
|
|
117
|
+
const isFillA = plotA.options.style === 'fill';
|
|
118
|
+
const isFillB = plotB.options.style === 'fill';
|
|
119
|
+
if (isFillA && !isFillB) return 1;
|
|
120
|
+
if (!isFillA && isFillB) return -1;
|
|
121
|
+
return 0;
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
sortedPlots.forEach((plotName) => {
|
|
125
|
+
const plot = indicator.plots[plotName];
|
|
126
|
+
const seriesName = `${id}::${plotName}`;
|
|
127
|
+
|
|
128
|
+
// Find axis index for THIS SPECIFIC PLOT
|
|
129
|
+
let xAxisIndex = 0;
|
|
130
|
+
let yAxisIndex = 0;
|
|
131
|
+
|
|
132
|
+
// Check plot-level overlay setting (overrides indicator-level setting)
|
|
133
|
+
// IMPORTANT: If indicator is overlay (paneIndex === 0), treat all plots as overlays
|
|
134
|
+
// This allows visual-only plots (background, barcolor) to have separate Y-axes while
|
|
135
|
+
// still being on the main chart pane
|
|
136
|
+
const plotOverlay = plot.options.overlay;
|
|
137
|
+
const isPlotOverlay = indicator.paneIndex === 0 || plotOverlay === true;
|
|
138
|
+
|
|
139
|
+
if (isPlotOverlay) {
|
|
140
|
+
// Plot should be on main chart (overlay)
|
|
141
|
+
xAxisIndex = 0;
|
|
142
|
+
if (overlayYAxisMap && overlayYAxisMap.has(seriesName)) {
|
|
143
|
+
// This specific plot has its own Y-axis (incompatible with price range)
|
|
144
|
+
yAxisIndex = overlayYAxisMap.get(seriesName)!;
|
|
145
|
+
} else {
|
|
146
|
+
// Shares main Y-axis with candlesticks
|
|
147
|
+
yAxisIndex = 0;
|
|
148
|
+
}
|
|
149
|
+
} else {
|
|
150
|
+
// Plot should be in indicator's separate pane
|
|
151
|
+
const confIndex = paneLayout.findIndex((p) => p.index === indicator.paneIndex);
|
|
152
|
+
if (confIndex !== -1) {
|
|
153
|
+
xAxisIndex = confIndex + 1;
|
|
154
|
+
yAxisIndex = separatePaneYAxisOffset + confIndex;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Prepare data arrays
|
|
159
|
+
// For 'fill' style, we don't use plot.data directly in the same way, but we initialize generic arrays
|
|
160
|
+
const dataArray = new Array(totalDataLength).fill(null);
|
|
161
|
+
const colorArray = new Array(totalDataLength).fill(null);
|
|
162
|
+
const optionsArray = new Array(totalDataLength).fill(null); // Store per-point options
|
|
163
|
+
|
|
164
|
+
plot.data?.forEach((point) => {
|
|
165
|
+
const index = timeToIndex.get(point.time);
|
|
166
|
+
if (index !== undefined) {
|
|
167
|
+
const plotOffset = point.options?.offset ?? plot.options.offset ?? 0;
|
|
168
|
+
const offsetIndex = index + dataIndexOffset + plotOffset;
|
|
169
|
+
|
|
170
|
+
if (offsetIndex >= 0 && offsetIndex < totalDataLength) {
|
|
171
|
+
let value = point.value;
|
|
172
|
+
const pointColor = point.options?.color;
|
|
173
|
+
|
|
174
|
+
// TradingView compatibility: if color is 'na' (NaN, null, or "na"), break the line
|
|
175
|
+
const isNaColor =
|
|
176
|
+
pointColor === null ||
|
|
177
|
+
pointColor === 'na' ||
|
|
178
|
+
pointColor === 'NaN' ||
|
|
179
|
+
(typeof pointColor === 'number' && isNaN(pointColor));
|
|
180
|
+
|
|
181
|
+
if (isNaColor) {
|
|
182
|
+
value = null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
dataArray[offsetIndex] = value;
|
|
186
|
+
colorArray[offsetIndex] = pointColor || plot.options.color || SeriesBuilder.DEFAULT_COLOR;
|
|
187
|
+
optionsArray[offsetIndex] = point.options || {};
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Store data array for fill plots to reference
|
|
193
|
+
// Only store for non-fill plots as fill plots don't produce data to be referenced by other fills (usually)
|
|
194
|
+
plotDataArrays.set(`${id}::${plotName}`, dataArray);
|
|
195
|
+
|
|
196
|
+
if (plot.options?.style?.startsWith('style_')) {
|
|
197
|
+
plot.options.style = plot.options.style.replace('style_', '') as IndicatorStyle;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Handle barcolor specifically as it modifies shared state (barColors)
|
|
201
|
+
if (plot.options.style === 'barcolor') {
|
|
202
|
+
// Apply colors to main chart candlesticks
|
|
203
|
+
plot.data?.forEach((point) => {
|
|
204
|
+
const index = timeToIndex.get(point.time);
|
|
205
|
+
if (index !== undefined) {
|
|
206
|
+
const plotOffset = point.options?.offset ?? plot.options.offset ?? 0;
|
|
207
|
+
const offsetIndex = index + dataIndexOffset + plotOffset;
|
|
208
|
+
|
|
209
|
+
if (offsetIndex >= 0 && offsetIndex < totalDataLength) {
|
|
210
|
+
const pointColor = point.options?.color || plot.options.color || SeriesBuilder.DEFAULT_COLOR;
|
|
211
|
+
const isNaColor =
|
|
212
|
+
pointColor === null ||
|
|
213
|
+
pointColor === 'na' ||
|
|
214
|
+
pointColor === 'NaN' ||
|
|
215
|
+
(typeof pointColor === 'number' && isNaN(pointColor));
|
|
216
|
+
|
|
217
|
+
if (!isNaColor && point.value !== null && point.value !== undefined) {
|
|
218
|
+
barColors[offsetIndex] = pointColor;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
return; // Skip rendering a series for barcolor
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Use Factory to get appropriate renderer
|
|
227
|
+
const renderer = SeriesRendererFactory.get(plot.options.style);
|
|
228
|
+
const seriesConfig = renderer.render({
|
|
229
|
+
seriesName,
|
|
230
|
+
xAxisIndex,
|
|
231
|
+
yAxisIndex,
|
|
232
|
+
dataArray,
|
|
233
|
+
colorArray,
|
|
234
|
+
optionsArray,
|
|
235
|
+
plotOptions: plot.options,
|
|
236
|
+
candlestickData,
|
|
237
|
+
plotDataArrays,
|
|
238
|
+
indicatorId: id,
|
|
239
|
+
plotName: plotName
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
if (seriesConfig) {
|
|
243
|
+
series.push(seriesConfig);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
return { series, barColors };
|
|
249
|
+
}
|
|
250
|
+
}
|