@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.
@@ -1,941 +1,250 @@
1
- import { OHLCV, Indicator as IndicatorType, QFChartOptions, IndicatorPlot, IndicatorStyle } from '../types';
2
- import { PaneConfiguration } from './LayoutManager';
3
- import { textToBase64Image } from '../Utils';
4
-
5
- export class SeriesBuilder {
6
- private static readonly DEFAULT_COLOR = '#2962ff';
7
-
8
- /**
9
- * Parse color string and extract opacity
10
- * Supports: hex (#RRGGBB), named colors (green, red), rgba(r,g,b,a), rgb(r,g,b)
11
- */
12
- private static parseColor(colorStr: string): { color: string; opacity: number } {
13
- if (!colorStr) {
14
- return { color: '#888888', opacity: 0.2 };
15
- }
16
-
17
- // Check for rgba format
18
- const rgbaMatch = colorStr.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
19
- if (rgbaMatch) {
20
- const r = rgbaMatch[1];
21
- const g = rgbaMatch[2];
22
- const b = rgbaMatch[3];
23
- const a = rgbaMatch[4] ? parseFloat(rgbaMatch[4]) : 1;
24
-
25
- // Return rgb color and separate opacity
26
- return {
27
- color: `rgb(${r},${g},${b})`,
28
- opacity: a,
29
- };
30
- }
31
-
32
- // For hex or named colors, default opacity to 0.3 for fill areas
33
- return {
34
- color: colorStr,
35
- opacity: 0.3,
36
- };
37
- }
38
-
39
- public static buildCandlestickSeries(marketData: OHLCV[], options: QFChartOptions, totalLength?: number): any {
40
- const upColor = options.upColor || '#00da3c';
41
- const downColor = options.downColor || '#ec0000';
42
-
43
- const data = marketData.map((d) => [d.open, d.close, d.low, d.high]);
44
-
45
- // Pad with nulls if totalLength is provided and greater than current data length
46
- if (totalLength && totalLength > data.length) {
47
- const padding = totalLength - data.length;
48
- for (let i = 0; i < padding; i++) {
49
- data.push(null as any);
50
- }
51
- }
52
-
53
- // Build markLine for last price if enabled
54
- let markLine = undefined;
55
- if (options.lastPriceLine?.visible !== false && marketData.length > 0) {
56
- const lastBar = marketData[marketData.length - 1];
57
- const lastClose = lastBar.close;
58
- const isUp = lastBar.close >= lastBar.open;
59
- // Use configured color, or dynamic color based on candle direction
60
- const lineColor = options.lastPriceLine?.color || (isUp ? upColor : downColor);
61
- let lineStyleType = options.lastPriceLine?.lineStyle || 'dashed';
62
-
63
- if (lineStyleType.startsWith('linestyle_')) {
64
- lineStyleType = lineStyleType.replace('linestyle_', '') as any;
65
- }
66
- markLine = {
67
- symbol: ['none', 'none'],
68
- data: [
69
- {
70
- yAxis: lastClose,
71
- label: {
72
- show: true,
73
- position: 'end', // Right side
74
- formatter: (params: any) => {
75
- // Respect Y-axis formatting options
76
- if (options.yAxisLabelFormatter) {
77
- return options.yAxisLabelFormatter(params.value);
78
- }
79
- const decimals = options.yAxisDecimalPlaces !== undefined ? options.yAxisDecimalPlaces : 2;
80
- return typeof params.value === 'number' ? params.value.toFixed(decimals) : params.value;
81
- },
82
- color: '#fff',
83
- backgroundColor: lineColor,
84
- padding: [2, 4],
85
- borderRadius: 2,
86
- fontSize: 11,
87
- fontWeight: 'bold',
88
- },
89
- lineStyle: {
90
- color: lineColor,
91
- type: lineStyleType,
92
- width: 1,
93
- opacity: 0.8,
94
- },
95
- },
96
- ],
97
- animation: false,
98
- silent: true, // Disable interaction
99
- };
100
- }
101
-
102
- return {
103
- type: 'candlestick',
104
- name: options.title || 'Market',
105
- data: data,
106
- itemStyle: {
107
- color: upColor,
108
- color0: downColor,
109
- borderColor: upColor,
110
- borderColor0: downColor,
111
- },
112
- markLine: markLine,
113
- xAxisIndex: 0,
114
- yAxisIndex: 0,
115
- z: 5,
116
- };
117
- }
118
-
119
- private static getShapeSymbol(shape: string): string {
120
- // SVG Paths need to be:
121
- // 1. Valid SVG path data strings
122
- // 2. Ideally centered around the origin or a standard box (e.g., 0 0 24 24)
123
- // 3. ECharts path:// format expects just the path data usually, but complex shapes might need 'image://' or better paths.
124
- // For simple shapes, standard ECharts symbols or simple paths work.
125
-
126
- switch (shape) {
127
- case 'arrowdown':
128
- // Blocky arrow down
129
- return 'path://M12 24l-12-12h8v-12h8v12h8z';
130
-
131
- case 'arrowup':
132
- // Blocky arrow up
133
- return 'path://M12 0l12 12h-8v12h-8v-12h-8z';
134
-
135
- case 'circle':
136
- return 'circle';
137
-
138
- case 'cross':
139
- // Plus sign (+)
140
- return 'path://M11 2h2v9h9v2h-9v9h-2v-9h-9v-2h9z';
141
-
142
- case 'diamond':
143
- return 'diamond'; // Built-in
144
-
145
- case 'flag':
146
- // Flag on a pole
147
- return 'path://M6 2v20h2v-8h12l-2-6 2-6h-12z';
148
-
149
- case 'labeldown':
150
- // Bubble pointing down: Rounded rect with a triangle at bottom
151
- return 'path://M4 2h16a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-6l-2 4l-2 -4h-6a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2z';
152
-
153
- case 'labelup':
154
- // Bubble pointing up: Rounded rect with triangle at top
155
- return 'path://M12 2l2 4h6a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-16a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h6z';
156
-
157
- case 'square':
158
- return 'rect';
159
-
160
- case 'triangledown':
161
- // Pointing down
162
- return 'path://M12 21l-10-18h20z';
163
-
164
- case 'triangleup':
165
- // Pointing up
166
- return 'triangle'; // Built-in is pointing up
167
-
168
- case 'xcross':
169
- // 'X' shape
170
- return 'path://M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z';
171
-
172
- default:
173
- return 'circle';
174
- }
175
- }
176
-
177
- private static getShapeRotation(shape: string): number {
178
- // With custom paths defined above, we might not need rotation unless we reuse shapes.
179
- // Built-in triangle is UP.
180
- return 0;
181
- }
182
-
183
- private static getShapeSize(size: string, width?: number, height?: number): number | number[] {
184
- // If both width and height are specified, use them directly
185
- if (width !== undefined && height !== undefined) {
186
- return [width, height];
187
- }
188
-
189
- // Base size from the size parameter
190
- let baseSize: number;
191
- switch (size) {
192
- case 'tiny':
193
- baseSize = 8;
194
- break;
195
- case 'small':
196
- baseSize = 12;
197
- break;
198
- case 'normal':
199
- case 'auto':
200
- baseSize = 16;
201
- break;
202
- case 'large':
203
- baseSize = 24;
204
- break;
205
- case 'huge':
206
- baseSize = 32;
207
- break;
208
- default:
209
- baseSize = 16;
210
- }
211
-
212
- // If only width is specified, preserve aspect ratio (assume square default)
213
- if (width !== undefined) {
214
- return [width, width];
215
- }
216
-
217
- // If only height is specified, preserve aspect ratio (assume square default)
218
- if (height !== undefined) {
219
- return [height, height];
220
- }
221
-
222
- // Default uniform size
223
- return baseSize;
224
- }
225
-
226
- // Helper to determine label position and distance relative to shape BASED ON LOCATION
227
- private static getLabelConfig(shape: string, location: string): { position: string; distance: number } {
228
- // Text position should be determined by location, not shape direction
229
-
230
- switch (location) {
231
- case 'abovebar':
232
- // Shape is above the candle, text should be above the shape
233
- return { position: 'top', distance: 5 };
234
-
235
- case 'belowbar':
236
- // Shape is below the candle, text should be below the shape
237
- return { position: 'bottom', distance: 5 };
238
-
239
- case 'top':
240
- // Shape at top of chart, text below it
241
- return { position: 'bottom', distance: 5 };
242
-
243
- case 'bottom':
244
- // Shape at bottom of chart, text above it
245
- return { position: 'top', distance: 5 };
246
-
247
- case 'absolute':
248
- default:
249
- // For labelup/down, text is INSIDE the shape
250
- if (shape === 'labelup' || shape === 'labeldown') {
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
+ }