@qfo/qfchart 0.7.3 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +368 -14
- package/dist/qfchart.min.browser.js +34 -16
- package/dist/qfchart.min.es.js +34 -16
- package/package.json +1 -1
- package/src/QFChart.ts +460 -311
- package/src/components/AbstractPlugin.ts +234 -104
- package/src/components/DrawingEditor.ts +297 -248
- package/src/components/DrawingRendererRegistry.ts +13 -0
- package/src/components/GraphicBuilder.ts +284 -263
- package/src/components/LayoutManager.ts +72 -55
- package/src/components/SeriesBuilder.ts +110 -6
- package/src/components/TableCanvasRenderer.ts +467 -0
- package/src/components/TableOverlayRenderer.ts +38 -9
- package/src/components/TooltipFormatter.ts +97 -97
- package/src/components/renderers/BackgroundRenderer.ts +59 -47
- package/src/components/renderers/BoxRenderer.ts +113 -17
- package/src/components/renderers/FillRenderer.ts +118 -3
- package/src/components/renderers/LabelRenderer.ts +35 -9
- package/src/components/renderers/OHLCBarRenderer.ts +171 -161
- package/src/components/renderers/PolylineRenderer.ts +26 -19
- package/src/index.ts +17 -6
- package/src/plugins/ABCDPatternTool/ABCDPatternDrawingRenderer.ts +112 -0
- package/src/plugins/ABCDPatternTool/ABCDPatternTool.ts +136 -0
- package/src/plugins/ABCDPatternTool/index.ts +2 -0
- package/src/plugins/CypherPatternTool/CypherPatternDrawingRenderer.ts +80 -0
- package/src/plugins/CypherPatternTool/CypherPatternTool.ts +84 -0
- package/src/plugins/CypherPatternTool/index.ts +2 -0
- package/src/plugins/FibSpeedResistanceFanTool/FibSpeedResistanceFanDrawingRenderer.ts +163 -0
- package/src/plugins/FibSpeedResistanceFanTool/FibSpeedResistanceFanTool.ts +210 -0
- package/src/plugins/FibSpeedResistanceFanTool/index.ts +2 -0
- package/src/plugins/FibTrendExtensionTool/FibTrendExtensionDrawingRenderer.ts +141 -0
- package/src/plugins/FibTrendExtensionTool/FibTrendExtensionTool.ts +188 -0
- package/src/plugins/FibTrendExtensionTool/index.ts +2 -0
- package/src/plugins/FibonacciChannelTool/FibonacciChannelDrawingRenderer.ts +128 -0
- package/src/plugins/FibonacciChannelTool/FibonacciChannelTool.ts +231 -0
- package/src/plugins/FibonacciChannelTool/index.ts +2 -0
- package/src/plugins/FibonacciTool/FibonacciDrawingRenderer.ts +107 -0
- package/src/plugins/{FibonacciTool.ts → FibonacciTool/FibonacciTool.ts} +195 -192
- package/src/plugins/FibonacciTool/index.ts +2 -0
- package/src/plugins/HeadAndShouldersTool/HeadAndShouldersDrawingRenderer.ts +95 -0
- package/src/plugins/HeadAndShouldersTool/HeadAndShouldersTool.ts +97 -0
- package/src/plugins/HeadAndShouldersTool/index.ts +2 -0
- package/src/plugins/LineTool/LineDrawingRenderer.ts +49 -0
- package/src/plugins/{LineTool.ts → LineTool/LineTool.ts} +161 -190
- package/src/plugins/LineTool/index.ts +2 -0
- package/src/plugins/{MeasureTool.ts → MeasureTool/MeasureTool.ts} +324 -344
- package/src/plugins/MeasureTool/index.ts +1 -0
- package/src/plugins/ThreeDrivesPatternTool/ThreeDrivesPatternDrawingRenderer.ts +106 -0
- package/src/plugins/ThreeDrivesPatternTool/ThreeDrivesPatternTool.ts +98 -0
- package/src/plugins/ThreeDrivesPatternTool/index.ts +2 -0
- package/src/plugins/ToolGroup.ts +211 -0
- package/src/plugins/TrianglePatternTool/TrianglePatternDrawingRenderer.ts +107 -0
- package/src/plugins/TrianglePatternTool/TrianglePatternTool.ts +98 -0
- package/src/plugins/TrianglePatternTool/index.ts +2 -0
- package/src/plugins/XABCDPatternTool/XABCDPatternDrawingRenderer.ts +178 -0
- package/src/plugins/XABCDPatternTool/XABCDPatternTool.ts +213 -0
- package/src/plugins/XABCDPatternTool/index.ts +2 -0
- package/src/types.ts +39 -4
- package/src/utils/ColorUtils.ts +1 -1
package/src/QFChart.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as echarts from 'echarts';
|
|
2
|
-
import { OHLCV, IndicatorPlot, QFChartOptions, Indicator as IndicatorInterface, ChartContext, Plugin } from './types';
|
|
2
|
+
import { OHLCV, IndicatorPlot, QFChartOptions, Indicator as IndicatorInterface, ChartContext, Plugin, DrawingRenderer } from './types';
|
|
3
3
|
import { Indicator } from './components/Indicator';
|
|
4
4
|
import { LayoutManager, LayoutResult, PaneBoundary } from './components/LayoutManager';
|
|
5
5
|
import { SeriesBuilder } from './components/SeriesBuilder';
|
|
@@ -7,9 +7,11 @@ import { GraphicBuilder } from './components/GraphicBuilder';
|
|
|
7
7
|
import { TooltipFormatter } from './components/TooltipFormatter';
|
|
8
8
|
import { PluginManager } from './components/PluginManager';
|
|
9
9
|
import { DrawingEditor } from './components/DrawingEditor';
|
|
10
|
+
import { DrawingRendererRegistry } from './components/DrawingRendererRegistry';
|
|
10
11
|
import { EventBus } from './utils/EventBus';
|
|
11
12
|
import { AxisUtils } from './utils/AxisUtils';
|
|
12
13
|
import { TableOverlayRenderer } from './components/TableOverlayRenderer';
|
|
14
|
+
import { TableCanvasRenderer } from './components/TableCanvasRenderer';
|
|
13
15
|
|
|
14
16
|
export class QFChart implements ChartContext {
|
|
15
17
|
private chart: echarts.ECharts;
|
|
@@ -28,6 +30,7 @@ export class QFChart implements ChartContext {
|
|
|
28
30
|
|
|
29
31
|
// Drawing System
|
|
30
32
|
private drawings: import('./types').DrawingElement[] = [];
|
|
33
|
+
private drawingRenderers: DrawingRendererRegistry = new DrawingRendererRegistry();
|
|
31
34
|
|
|
32
35
|
public coordinateConversion = {
|
|
33
36
|
pixelToData: (point: { x: number; y: number }) => {
|
|
@@ -54,9 +57,11 @@ export class QFChart implements ChartContext {
|
|
|
54
57
|
const pGrid = this.chart.convertFromPixel({ gridIndex: i }, [point.x, point.y]);
|
|
55
58
|
|
|
56
59
|
if (pGrid) {
|
|
57
|
-
// Store
|
|
58
|
-
// This
|
|
59
|
-
|
|
60
|
+
// Store in real data indices (subtract padding offset).
|
|
61
|
+
// This makes drawing coordinates independent of lazy padding
|
|
62
|
+
// expansion — when _resizePadding() changes dataIndexOffset,
|
|
63
|
+
// stored coordinates stay valid without manual updating.
|
|
64
|
+
return { timeIndex: Math.round(pGrid[0]) - this.dataIndexOffset, value: pGrid[1], paneIndex: i };
|
|
60
65
|
}
|
|
61
66
|
}
|
|
62
67
|
}
|
|
@@ -64,8 +69,8 @@ export class QFChart implements ChartContext {
|
|
|
64
69
|
},
|
|
65
70
|
dataToPixel: (point: { timeIndex: number; value: number; paneIndex?: number }) => {
|
|
66
71
|
const paneIdx = point.paneIndex || 0;
|
|
67
|
-
//
|
|
68
|
-
const p = this.chart.convertToPixel({ gridIndex: paneIdx }, [point.timeIndex, point.value]);
|
|
72
|
+
// Convert real data index back to padded space for ECharts
|
|
73
|
+
const p = this.chart.convertToPixel({ gridIndex: paneIdx }, [point.timeIndex + this.dataIndexOffset, point.value]);
|
|
69
74
|
if (p) {
|
|
70
75
|
return { x: p[0], y: p[1] };
|
|
71
76
|
}
|
|
@@ -79,6 +84,12 @@ export class QFChart implements ChartContext {
|
|
|
79
84
|
private readonly defaultPadding = 0.0;
|
|
80
85
|
private padding: number;
|
|
81
86
|
private dataIndexOffset: number = 0; // Offset for phantom padding data
|
|
87
|
+
private _paddingPoints: number = 0; // Current symmetric padding (empty bars per side)
|
|
88
|
+
private readonly LAZY_MIN_PADDING = 5; // Always have a tiny buffer so edge scroll triggers
|
|
89
|
+
private readonly LAZY_MAX_PADDING = 500; // Hard cap per side
|
|
90
|
+
private readonly LAZY_CHUNK_SIZE = 50; // Bars added per expansion
|
|
91
|
+
private readonly LAZY_EDGE_THRESHOLD = 10; // Bars from edge to trigger
|
|
92
|
+
private _expandScheduled: boolean = false; // Debounce flag
|
|
82
93
|
|
|
83
94
|
// DOM Elements for Layout
|
|
84
95
|
private rootContainer: HTMLElement;
|
|
@@ -89,6 +100,9 @@ export class QFChart implements ChartContext {
|
|
|
89
100
|
private chartContainer: HTMLElement;
|
|
90
101
|
private overlayContainer: HTMLElement;
|
|
91
102
|
private _lastTables: any[] = [];
|
|
103
|
+
private _tableGraphicIds: string[] = []; // Track canvas table graphic IDs for cleanup
|
|
104
|
+
private _baseGraphics: any[] = []; // Non-table graphic elements (title, watermark, pane labels)
|
|
105
|
+
private _labelTooltipEl: HTMLElement | null = null; // Floating tooltip for label.set_tooltip()
|
|
92
106
|
|
|
93
107
|
// Pane drag-resize state
|
|
94
108
|
private _lastLayout: (LayoutResult & { overlayYAxisMap: Map<string, number>; separatePaneYAxisOffset: number }) | null = null;
|
|
@@ -105,7 +119,7 @@ export class QFChart implements ChartContext {
|
|
|
105
119
|
constructor(container: HTMLElement, options: QFChartOptions = {}) {
|
|
106
120
|
this.rootContainer = container;
|
|
107
121
|
this.options = {
|
|
108
|
-
title:
|
|
122
|
+
title: undefined,
|
|
109
123
|
height: '600px',
|
|
110
124
|
backgroundColor: '#1e293b',
|
|
111
125
|
upColor: '#00da3c',
|
|
@@ -195,12 +209,15 @@ export class QFChart implements ChartContext {
|
|
|
195
209
|
// Overlay container for table rendering (positioned above ECharts canvas)
|
|
196
210
|
this.chartContainer.style.position = 'relative';
|
|
197
211
|
this.overlayContainer = document.createElement('div');
|
|
198
|
-
this.overlayContainer.style.cssText =
|
|
212
|
+
this.overlayContainer.style.cssText =
|
|
213
|
+
'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:100;overflow:hidden;';
|
|
199
214
|
this.chartContainer.appendChild(this.overlayContainer);
|
|
200
215
|
|
|
201
216
|
this.pluginManager = new PluginManager(this, this.toolbarContainer);
|
|
202
217
|
this.drawingEditor = new DrawingEditor(this);
|
|
203
218
|
|
|
219
|
+
|
|
220
|
+
|
|
204
221
|
// Bind global chart/ZRender events to the EventBus
|
|
205
222
|
this.chart.on('dataZoom', (params: any) => {
|
|
206
223
|
this.events.emit('chart:dataZoom', params);
|
|
@@ -209,22 +226,28 @@ export class QFChart implements ChartContext {
|
|
|
209
226
|
const triggerOn = this.options.databox?.triggerOn;
|
|
210
227
|
const position = this.options.databox?.position;
|
|
211
228
|
if (triggerOn === 'click' && position === 'floating') {
|
|
212
|
-
|
|
213
|
-
this.chart.dispatchAction({
|
|
214
|
-
type: 'hideTip',
|
|
215
|
-
});
|
|
229
|
+
this.chart.dispatchAction({ type: 'hideTip' });
|
|
216
230
|
}
|
|
231
|
+
|
|
232
|
+
// Lazy padding: check if user scrolled near an edge
|
|
233
|
+
this._checkEdgeAndExpand();
|
|
217
234
|
});
|
|
218
235
|
// @ts-ignore - ECharts event handler type mismatch
|
|
219
236
|
this.chart.on('finished', (params: any) => this.events.emit('chart:updated', params)); // General chart update
|
|
220
237
|
// @ts-ignore - ECharts ZRender event handler type mismatch
|
|
221
|
-
this.chart.getZr().on('mousedown', (params: any) => {
|
|
238
|
+
this.chart.getZr().on('mousedown', (params: any) => {
|
|
239
|
+
if (!this._paneDragState) this.events.emit('mouse:down', params);
|
|
240
|
+
});
|
|
222
241
|
// @ts-ignore - ECharts ZRender event handler type mismatch
|
|
223
|
-
this.chart.getZr().on('mousemove', (params: any) => {
|
|
242
|
+
this.chart.getZr().on('mousemove', (params: any) => {
|
|
243
|
+
if (!this._paneDragState) this.events.emit('mouse:move', params);
|
|
244
|
+
});
|
|
224
245
|
// @ts-ignore - ECharts ZRender event handler type mismatch
|
|
225
246
|
this.chart.getZr().on('mouseup', (params: any) => this.events.emit('mouse:up', params));
|
|
226
247
|
// @ts-ignore - ECharts ZRender event handler type mismatch
|
|
227
|
-
this.chart.getZr().on('click', (params: any) => {
|
|
248
|
+
this.chart.getZr().on('click', (params: any) => {
|
|
249
|
+
if (!this._paneDragState) this.events.emit('mouse:click', params);
|
|
250
|
+
});
|
|
228
251
|
|
|
229
252
|
const zr = this.chart.getZr();
|
|
230
253
|
const originalSetCursorStyle = zr.setCursorStyle;
|
|
@@ -275,9 +298,9 @@ export class QFChart implements ChartContext {
|
|
|
275
298
|
|
|
276
299
|
// ── Pane border drag-resize ────────────────────────────────
|
|
277
300
|
private bindPaneResizeEvents(): void {
|
|
278
|
-
const MIN_MAIN = 10;
|
|
301
|
+
const MIN_MAIN = 10; // minimum main pane height %
|
|
279
302
|
const MIN_INDICATOR = 5; // minimum indicator pane height %
|
|
280
|
-
const HIT_ZONE = 6;
|
|
303
|
+
const HIT_ZONE = 6; // hit-zone in pixels (±3px from boundary center)
|
|
281
304
|
|
|
282
305
|
const zr = this.chart.getZr();
|
|
283
306
|
|
|
@@ -488,8 +511,8 @@ export class QFChart implements ChartContext {
|
|
|
488
511
|
});
|
|
489
512
|
// Set cursor
|
|
490
513
|
this.chart.getZr().setCursorStyle('move');
|
|
491
|
-
} else if (info.targetName?.startsWith('point')) {
|
|
492
|
-
const pointIdx = info.targetName
|
|
514
|
+
} else if (info.targetName?.startsWith('point-')) {
|
|
515
|
+
const pointIdx = parseInt(info.targetName.split('-')[1]) || 0;
|
|
493
516
|
this.events.emit('drawing:point:hover', {
|
|
494
517
|
id: info.drawing.id,
|
|
495
518
|
pointIndex: pointIdx,
|
|
@@ -530,8 +553,8 @@ export class QFChart implements ChartContext {
|
|
|
530
553
|
|
|
531
554
|
if (info.targetName === 'line') {
|
|
532
555
|
this.events.emit('drawing:mouseout', { id: info.drawing.id });
|
|
533
|
-
} else if (info.targetName?.startsWith('point')) {
|
|
534
|
-
const pointIdx = info.targetName
|
|
556
|
+
} else if (info.targetName?.startsWith('point-')) {
|
|
557
|
+
const pointIdx = parseInt(info.targetName.split('-')[1]) || 0;
|
|
535
558
|
this.events.emit('drawing:point:mouseout', {
|
|
536
559
|
id: info.drawing.id,
|
|
537
560
|
pointIndex: pointIdx,
|
|
@@ -554,8 +577,8 @@ export class QFChart implements ChartContext {
|
|
|
554
577
|
x,
|
|
555
578
|
y,
|
|
556
579
|
});
|
|
557
|
-
} else if (info.targetName?.startsWith('point')) {
|
|
558
|
-
const pointIdx = info.targetName
|
|
580
|
+
} else if (info.targetName?.startsWith('point-')) {
|
|
581
|
+
const pointIdx = parseInt(info.targetName.split('-')[1]) || 0;
|
|
559
582
|
this.events.emit('drawing:point:mousedown', {
|
|
560
583
|
id: info.drawing.id,
|
|
561
584
|
pointIndex: pointIdx,
|
|
@@ -578,8 +601,8 @@ export class QFChart implements ChartContext {
|
|
|
578
601
|
|
|
579
602
|
if (info.targetName === 'line') {
|
|
580
603
|
this.events.emit('drawing:click', { id: info.drawing.id });
|
|
581
|
-
} else if (info.targetName?.startsWith('point')) {
|
|
582
|
-
const pointIdx = info.targetName
|
|
604
|
+
} else if (info.targetName?.startsWith('point-')) {
|
|
605
|
+
const pointIdx = parseInt(info.targetName.split('-')[1]) || 0;
|
|
583
606
|
this.events.emit('drawing:point:click', {
|
|
584
607
|
id: info.drawing.id,
|
|
585
608
|
pointIndex: pointIdx,
|
|
@@ -598,6 +621,47 @@ export class QFChart implements ChartContext {
|
|
|
598
621
|
}
|
|
599
622
|
}
|
|
600
623
|
});
|
|
624
|
+
|
|
625
|
+
// --- Label Tooltip ---
|
|
626
|
+
// Create floating tooltip overlay for Pine Script label.set_tooltip()
|
|
627
|
+
this._labelTooltipEl = document.createElement('div');
|
|
628
|
+
this._labelTooltipEl.style.cssText =
|
|
629
|
+
'position:absolute;display:none;pointer-events:none;z-index:200;' +
|
|
630
|
+
'background:rgba(30,41,59,0.95);color:#fff;border:1px solid #475569;' +
|
|
631
|
+
'border-radius:4px;padding:6px 10px;font-size:12px;line-height:1.5;' +
|
|
632
|
+
'white-space:pre-wrap;max-width:350px;box-shadow:0 2px 8px rgba(0,0,0,0.3);' +
|
|
633
|
+
'font-family:' +
|
|
634
|
+
(this.options.fontFamily || 'sans-serif') +
|
|
635
|
+
';';
|
|
636
|
+
this.chartContainer.appendChild(this._labelTooltipEl);
|
|
637
|
+
|
|
638
|
+
// Show tooltip on scatter item hover (labels with tooltip text)
|
|
639
|
+
this.chart.on('mouseover', { seriesType: 'scatter' }, (params: any) => {
|
|
640
|
+
const tooltipText = params.data?._tooltipText;
|
|
641
|
+
if (!tooltipText || !this._labelTooltipEl) return;
|
|
642
|
+
|
|
643
|
+
this._labelTooltipEl.textContent = tooltipText;
|
|
644
|
+
this._labelTooltipEl.style.display = 'block';
|
|
645
|
+
|
|
646
|
+
// Position below the scatter point
|
|
647
|
+
const chartRect = this.chartContainer.getBoundingClientRect();
|
|
648
|
+
const event = params.event?.event;
|
|
649
|
+
if (event) {
|
|
650
|
+
const x = event.clientX - chartRect.left;
|
|
651
|
+
const y = event.clientY - chartRect.top;
|
|
652
|
+
// Show below and slightly left of cursor
|
|
653
|
+
const tipWidth = this._labelTooltipEl.offsetWidth;
|
|
654
|
+
const left = Math.min(x - tipWidth / 2, chartRect.width - tipWidth - 8);
|
|
655
|
+
this._labelTooltipEl.style.left = Math.max(4, left) + 'px';
|
|
656
|
+
this._labelTooltipEl.style.top = y + 18 + 'px';
|
|
657
|
+
}
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
this.chart.on('mouseout', { seriesType: 'scatter' }, () => {
|
|
661
|
+
if (this._labelTooltipEl) {
|
|
662
|
+
this._labelTooltipEl.style.display = 'none';
|
|
663
|
+
}
|
|
664
|
+
});
|
|
601
665
|
}
|
|
602
666
|
|
|
603
667
|
// --- Plugin System Integration ---
|
|
@@ -626,6 +690,64 @@ export class QFChart implements ChartContext {
|
|
|
626
690
|
this.pluginManager.register(plugin);
|
|
627
691
|
}
|
|
628
692
|
|
|
693
|
+
public registerDrawingRenderer(renderer: DrawingRenderer): void {
|
|
694
|
+
this.drawingRenderers.register(renderer);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
public snapToCandle(point: { x: number; y: number }): { x: number; y: number } {
|
|
698
|
+
// Find which pane the point is in
|
|
699
|
+
const dataCoord = this.coordinateConversion.pixelToData(point);
|
|
700
|
+
if (!dataCoord) return point;
|
|
701
|
+
|
|
702
|
+
const paneIndex = dataCoord.paneIndex || 0;
|
|
703
|
+
// Only snap on the main pane (candlestick data)
|
|
704
|
+
if (paneIndex !== 0) return point;
|
|
705
|
+
|
|
706
|
+
// Get the nearest candle by time index
|
|
707
|
+
const realIndex = Math.round(dataCoord.timeIndex);
|
|
708
|
+
if (realIndex < 0 || realIndex >= this.marketData.length) return point;
|
|
709
|
+
|
|
710
|
+
const candle = this.marketData[realIndex];
|
|
711
|
+
if (!candle) return point;
|
|
712
|
+
|
|
713
|
+
// Snap X to the exact candle center
|
|
714
|
+
const snappedX = this.chart.convertToPixel(
|
|
715
|
+
{ gridIndex: paneIndex },
|
|
716
|
+
[realIndex + this.dataIndexOffset, candle.close],
|
|
717
|
+
);
|
|
718
|
+
if (!snappedX) return point;
|
|
719
|
+
const snapPxX = snappedX[0];
|
|
720
|
+
|
|
721
|
+
// Find closest OHLC value by Y distance
|
|
722
|
+
const ohlc = [candle.open, candle.high, candle.low, candle.close];
|
|
723
|
+
let bestValue = ohlc[0];
|
|
724
|
+
let bestDist = Infinity;
|
|
725
|
+
|
|
726
|
+
for (const val of ohlc) {
|
|
727
|
+
const px = this.chart.convertToPixel(
|
|
728
|
+
{ gridIndex: paneIndex },
|
|
729
|
+
[realIndex + this.dataIndexOffset, val],
|
|
730
|
+
);
|
|
731
|
+
if (px) {
|
|
732
|
+
const dist = Math.abs(px[1] - point.y);
|
|
733
|
+
if (dist < bestDist) {
|
|
734
|
+
bestDist = dist;
|
|
735
|
+
bestValue = val;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const snappedY = this.chart.convertToPixel(
|
|
741
|
+
{ gridIndex: paneIndex },
|
|
742
|
+
[realIndex + this.dataIndexOffset, bestValue],
|
|
743
|
+
);
|
|
744
|
+
|
|
745
|
+
return {
|
|
746
|
+
x: snapPxX,
|
|
747
|
+
y: snappedY ? snappedY[1] : point.y,
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
|
|
629
751
|
// --- Drawing System ---
|
|
630
752
|
|
|
631
753
|
public addDrawing(drawing: import('./types').DrawingElement): void {
|
|
@@ -842,19 +964,20 @@ export class QFChart implements ChartContext {
|
|
|
842
964
|
paddingPoints,
|
|
843
965
|
paddedOHLCVForShapes, // Pass padded OHLCV data
|
|
844
966
|
layout.overlayYAxisMap, // Pass overlay Y-axis mapping
|
|
845
|
-
layout.separatePaneYAxisOffset // Pass Y-axis offset for separate panes
|
|
967
|
+
layout.separatePaneYAxisOffset, // Pass Y-axis offset for separate panes
|
|
846
968
|
);
|
|
847
969
|
|
|
848
970
|
// Apply barColors to candlestick data
|
|
971
|
+
// TradingView behavior: barcolor() only changes body fill; borders/wicks keep default colors (green/red)
|
|
849
972
|
const coloredCandlestickData = paddedCandlestickData.map((candle: any, i: number) => {
|
|
850
973
|
if (barColors[i]) {
|
|
974
|
+
const vals = candle.value || candle;
|
|
851
975
|
return {
|
|
852
|
-
value:
|
|
976
|
+
value: vals,
|
|
853
977
|
itemStyle: {
|
|
854
|
-
color: barColors[i],
|
|
855
|
-
color0: barColors[i],
|
|
856
|
-
borderColor
|
|
857
|
-
borderColor0: barColors[i],
|
|
978
|
+
color: barColors[i], // up-candle body fill
|
|
979
|
+
color0: barColors[i], // down-candle body fill
|
|
980
|
+
// borderColor/borderColor0 intentionally omitted → inherits series default (green/red)
|
|
858
981
|
},
|
|
859
982
|
};
|
|
860
983
|
}
|
|
@@ -871,16 +994,7 @@ export class QFChart implements ChartContext {
|
|
|
871
994
|
data: coloredCandlestickData,
|
|
872
995
|
markLine: candlestickSeries.markLine, // Ensure markLine is updated
|
|
873
996
|
},
|
|
874
|
-
...indicatorSeries
|
|
875
|
-
const update: any = { data: s.data };
|
|
876
|
-
// If the series has a renderItem function (custom series like background),
|
|
877
|
-
// we MUST update it because it likely closes over variables (colorArray)
|
|
878
|
-
// from the SeriesBuilder scope which have been recreated.
|
|
879
|
-
if (s.renderItem) {
|
|
880
|
-
update.renderItem = s.renderItem;
|
|
881
|
-
}
|
|
882
|
-
return update;
|
|
883
|
-
}),
|
|
997
|
+
...indicatorSeries,
|
|
884
998
|
],
|
|
885
999
|
};
|
|
886
1000
|
|
|
@@ -897,7 +1011,7 @@ export class QFChart implements ChartContext {
|
|
|
897
1011
|
tables.forEach((t: any) => {
|
|
898
1012
|
if (t && !t._deleted) {
|
|
899
1013
|
// Tag table with its indicator's pane for correct positioning
|
|
900
|
-
t._paneIndex =
|
|
1014
|
+
t._paneIndex = t.force_overlay ? 0 : indicator.paneIndex;
|
|
901
1015
|
allTables.push(t);
|
|
902
1016
|
}
|
|
903
1017
|
});
|
|
@@ -916,14 +1030,23 @@ export class QFChart implements ChartContext {
|
|
|
916
1030
|
// Stop existing timer
|
|
917
1031
|
this.stopCountdown();
|
|
918
1032
|
|
|
919
|
-
if (!this.options.lastPriceLine?.showCountdown ||
|
|
1033
|
+
if (!this.options.lastPriceLine?.showCountdown || this.marketData.length === 0) {
|
|
920
1034
|
return;
|
|
921
1035
|
}
|
|
922
1036
|
|
|
1037
|
+
// Auto-detect interval from market data if not explicitly set
|
|
1038
|
+
let interval = this.options.interval;
|
|
1039
|
+
if (!interval && this.marketData.length >= 2) {
|
|
1040
|
+
const last = this.marketData[this.marketData.length - 1];
|
|
1041
|
+
const prev = this.marketData[this.marketData.length - 2];
|
|
1042
|
+
interval = last.time - prev.time;
|
|
1043
|
+
}
|
|
1044
|
+
if (!interval) return;
|
|
1045
|
+
|
|
923
1046
|
const updateLabel = () => {
|
|
924
1047
|
if (this.marketData.length === 0) return;
|
|
925
1048
|
const lastBar = this.marketData[this.marketData.length - 1];
|
|
926
|
-
const nextCloseTime = lastBar.time +
|
|
1049
|
+
const nextCloseTime = lastBar.time + interval!;
|
|
927
1050
|
const now = Date.now();
|
|
928
1051
|
const diff = nextCloseTime - now;
|
|
929
1052
|
|
|
@@ -974,9 +1097,8 @@ export class QFChart implements ChartContext {
|
|
|
974
1097
|
if (this.options.yAxisLabelFormatter) {
|
|
975
1098
|
priceStr = this.options.yAxisLabelFormatter(price);
|
|
976
1099
|
} else {
|
|
977
|
-
const decimals =
|
|
978
|
-
? this.options.yAxisDecimalPlaces
|
|
979
|
-
: AxisUtils.autoDetectDecimals(this.marketData);
|
|
1100
|
+
const decimals =
|
|
1101
|
+
this.options.yAxisDecimalPlaces !== undefined ? this.options.yAxisDecimalPlaces : AxisUtils.autoDetectDecimals(this.marketData);
|
|
980
1102
|
priceStr = AxisUtils.formatValue(price, decimals);
|
|
981
1103
|
}
|
|
982
1104
|
|
|
@@ -989,7 +1111,7 @@ export class QFChart implements ChartContext {
|
|
|
989
1111
|
this.chart.setOption({
|
|
990
1112
|
series: [
|
|
991
1113
|
{
|
|
992
|
-
|
|
1114
|
+
id: '__candlestick__',
|
|
993
1115
|
markLine: {
|
|
994
1116
|
data: [
|
|
995
1117
|
{
|
|
@@ -1030,10 +1152,10 @@ export class QFChart implements ChartContext {
|
|
|
1030
1152
|
height?: number;
|
|
1031
1153
|
titleColor?: string;
|
|
1032
1154
|
controls?: { collapse?: boolean; maximize?: boolean };
|
|
1033
|
-
} = {}
|
|
1155
|
+
} = {},
|
|
1034
1156
|
): Indicator {
|
|
1035
1157
|
// Handle backward compatibility: prefer 'overlay' over 'isOverlay'
|
|
1036
|
-
const isOverlay = options.overlay !== undefined ? options.overlay : options.isOverlay ?? false;
|
|
1158
|
+
const isOverlay = options.overlay !== undefined ? options.overlay : (options.isOverlay ?? false);
|
|
1037
1159
|
let paneIndex = 0;
|
|
1038
1160
|
if (!isOverlay) {
|
|
1039
1161
|
// Find the next available pane index
|
|
@@ -1110,11 +1232,42 @@ export class QFChart implements ChartContext {
|
|
|
1110
1232
|
this._renderTableOverlays();
|
|
1111
1233
|
}
|
|
1112
1234
|
|
|
1113
|
-
|
|
1235
|
+
/**
|
|
1236
|
+
* Build table canvas graphic elements from the current _lastTables.
|
|
1237
|
+
* Must be called AFTER setOption so grid rects are available from ECharts.
|
|
1238
|
+
* Returns an array of ECharts graphic elements.
|
|
1239
|
+
*/
|
|
1240
|
+
private _buildTableGraphics(): any[] {
|
|
1114
1241
|
const model = this.chart.getModel() as any;
|
|
1115
|
-
const getGridRect = (paneIndex: number) =>
|
|
1116
|
-
|
|
1117
|
-
|
|
1242
|
+
const getGridRect = (paneIndex: number) => model.getComponent('grid', paneIndex)?.coordinateSystem?.getRect();
|
|
1243
|
+
const elements = TableCanvasRenderer.buildGraphicElements(this._lastTables, getGridRect);
|
|
1244
|
+
// Assign stable IDs for future merge/replace
|
|
1245
|
+
this._tableGraphicIds = [];
|
|
1246
|
+
for (let i = 0; i < elements.length; i++) {
|
|
1247
|
+
const id = `__qf_table_${i}`;
|
|
1248
|
+
elements[i].id = id;
|
|
1249
|
+
this._tableGraphicIds.push(id);
|
|
1250
|
+
}
|
|
1251
|
+
return elements;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
/**
|
|
1255
|
+
* Render table overlays after a non-replacing setOption (updateData, resize).
|
|
1256
|
+
* Uses replaceMerge to cleanly replace all graphic elements without disrupting
|
|
1257
|
+
* other interactive components (dataZoom, tooltip, etc.).
|
|
1258
|
+
*/
|
|
1259
|
+
private _renderTableOverlays(): void {
|
|
1260
|
+
// Build new table graphics
|
|
1261
|
+
const tableGraphics = this._buildTableGraphics();
|
|
1262
|
+
|
|
1263
|
+
// Combine base graphics (title, watermark) + table graphics and replace all at once.
|
|
1264
|
+
// Using replaceMerge: ['graphic'] replaces ONLY the graphic component,
|
|
1265
|
+
// leaving dataZoom, tooltip, series etc. untouched.
|
|
1266
|
+
const allGraphics = [...this._baseGraphics, ...tableGraphics];
|
|
1267
|
+
this.chart.setOption({ graphic: allGraphics }, { replaceMerge: ['graphic'] } as any);
|
|
1268
|
+
|
|
1269
|
+
// Clear DOM overlays (legacy) — keep overlay container empty
|
|
1270
|
+
TableOverlayRenderer.clearAll(this.overlayContainer);
|
|
1118
1271
|
}
|
|
1119
1272
|
|
|
1120
1273
|
public destroy(): void {
|
|
@@ -1133,10 +1286,198 @@ export class QFChart implements ChartContext {
|
|
|
1133
1286
|
this.timeToIndex.set(k.time, index);
|
|
1134
1287
|
});
|
|
1135
1288
|
|
|
1136
|
-
//
|
|
1289
|
+
// Calculate initial padding from user-configured ratio
|
|
1290
|
+
const dataLength = this.marketData.length;
|
|
1291
|
+
const initialPadding = Math.ceil(dataLength * this.padding);
|
|
1292
|
+
|
|
1293
|
+
// _paddingPoints can only grow (lazy expansion), never shrink below initial or minimum
|
|
1294
|
+
this._paddingPoints = Math.max(this._paddingPoints, initialPadding, this.LAZY_MIN_PADDING);
|
|
1295
|
+
this.dataIndexOffset = this._paddingPoints;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
/**
|
|
1299
|
+
* Expand symmetric padding to the given number of points per side.
|
|
1300
|
+
* No-op if newPaddingPoints <= current. Performs a full render() and
|
|
1301
|
+
* restores the viewport position so there is no visual jump.
|
|
1302
|
+
*/
|
|
1303
|
+
public expandPadding(newPaddingPoints: number): void {
|
|
1304
|
+
this._resizePadding(newPaddingPoints);
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
/**
|
|
1308
|
+
* Resize symmetric padding to the given number of points per side.
|
|
1309
|
+
* Works for both growing and shrinking. Clamps to [min, max].
|
|
1310
|
+
* Uses merge-mode setOption to preserve drag/interaction state.
|
|
1311
|
+
*/
|
|
1312
|
+
private _resizePadding(newPaddingPoints: number): void {
|
|
1313
|
+
// Clamp to bounds
|
|
1314
|
+
const initialPadding = Math.ceil(this.marketData.length * this.padding);
|
|
1315
|
+
newPaddingPoints = Math.max(newPaddingPoints, initialPadding, this.LAZY_MIN_PADDING);
|
|
1316
|
+
newPaddingPoints = Math.min(newPaddingPoints, this.LAZY_MAX_PADDING);
|
|
1317
|
+
if (newPaddingPoints === this._paddingPoints) return;
|
|
1318
|
+
|
|
1319
|
+
// 1. Capture current viewport as absolute bar indices
|
|
1320
|
+
const oldPadding = this._paddingPoints;
|
|
1321
|
+
const oldTotal = this.marketData.length + 2 * oldPadding;
|
|
1322
|
+
const currentOption = this.chart.getOption() as any;
|
|
1323
|
+
const zoomComp = currentOption?.dataZoom?.find((dz: any) => dz.type === 'slider' || dz.type === 'inside');
|
|
1324
|
+
const oldStartIdx = zoomComp ? (zoomComp.start / 100) * oldTotal : 0;
|
|
1325
|
+
const oldEndIdx = zoomComp ? (zoomComp.end / 100) * oldTotal : oldTotal;
|
|
1326
|
+
|
|
1327
|
+
// 2. Update padding state (delta can be positive or negative)
|
|
1328
|
+
const delta = newPaddingPoints - oldPadding;
|
|
1329
|
+
this._paddingPoints = newPaddingPoints;
|
|
1330
|
+
this.dataIndexOffset = this._paddingPoints;
|
|
1331
|
+
const paddingPoints = this._paddingPoints;
|
|
1332
|
+
|
|
1333
|
+
// 3. Rebuild all data arrays with new padding
|
|
1334
|
+
const emptyCandle = { value: [NaN, NaN, NaN, NaN], itemStyle: { opacity: 0 } };
|
|
1335
|
+
const candlestickSeries = SeriesBuilder.buildCandlestickSeries(this.marketData, this.options);
|
|
1336
|
+
const paddedCandlestickData = [
|
|
1337
|
+
...Array(paddingPoints).fill(emptyCandle),
|
|
1338
|
+
...candlestickSeries.data,
|
|
1339
|
+
...Array(paddingPoints).fill(emptyCandle),
|
|
1340
|
+
];
|
|
1341
|
+
const categoryData = [
|
|
1342
|
+
...Array(paddingPoints).fill(''),
|
|
1343
|
+
...this.marketData.map((k) => new Date(k.time).toLocaleString()),
|
|
1344
|
+
...Array(paddingPoints).fill(''),
|
|
1345
|
+
];
|
|
1346
|
+
const paddedOHLCVForShapes = [...Array(paddingPoints).fill(null), ...this.marketData, ...Array(paddingPoints).fill(null)];
|
|
1347
|
+
|
|
1348
|
+
// Rebuild indicator series with new offset
|
|
1349
|
+
const layout = LayoutManager.calculate(
|
|
1350
|
+
this.chart.getHeight(),
|
|
1351
|
+
this.indicators,
|
|
1352
|
+
this.options,
|
|
1353
|
+
this.isMainCollapsed,
|
|
1354
|
+
this.maximizedPaneId,
|
|
1355
|
+
this.marketData,
|
|
1356
|
+
this._mainHeightOverride ?? undefined,
|
|
1357
|
+
);
|
|
1358
|
+
const { series: indicatorSeries, barColors } = SeriesBuilder.buildIndicatorSeries(
|
|
1359
|
+
this.indicators,
|
|
1360
|
+
this.timeToIndex,
|
|
1361
|
+
layout.paneLayout,
|
|
1362
|
+
categoryData.length,
|
|
1363
|
+
paddingPoints,
|
|
1364
|
+
paddedOHLCVForShapes,
|
|
1365
|
+
layout.overlayYAxisMap,
|
|
1366
|
+
layout.separatePaneYAxisOffset,
|
|
1367
|
+
);
|
|
1368
|
+
|
|
1369
|
+
// Apply barColors (TradingView: barcolor() only changes body fill, borders/wicks stay default)
|
|
1370
|
+
const coloredCandlestickData = paddedCandlestickData.map((candle: any, i: number) => {
|
|
1371
|
+
if (barColors[i]) {
|
|
1372
|
+
const vals = candle.value || candle;
|
|
1373
|
+
return {
|
|
1374
|
+
value: vals,
|
|
1375
|
+
itemStyle: {
|
|
1376
|
+
color: barColors[i],
|
|
1377
|
+
color0: barColors[i],
|
|
1378
|
+
},
|
|
1379
|
+
};
|
|
1380
|
+
}
|
|
1381
|
+
return candle;
|
|
1382
|
+
});
|
|
1383
|
+
|
|
1384
|
+
// 4. Calculate corrected zoom for new total length
|
|
1385
|
+
const newTotal = this.marketData.length + 2 * newPaddingPoints;
|
|
1386
|
+
const newStart = Math.max(0, ((oldStartIdx + delta) / newTotal) * 100);
|
|
1387
|
+
const newEnd = Math.min(100, ((oldEndIdx + delta) / newTotal) * 100);
|
|
1388
|
+
|
|
1389
|
+
// 5. Rebuild drawing series data with new offset so ECharts
|
|
1390
|
+
// viewport culling uses correct padded indices after expansion.
|
|
1391
|
+
const drawingSeriesUpdates: any[] = [];
|
|
1392
|
+
const drawingsByPane = new Map<number, import('./types').DrawingElement[]>();
|
|
1393
|
+
this.drawings.forEach((d) => {
|
|
1394
|
+
const paneIdx = d.paneIndex || 0;
|
|
1395
|
+
if (!drawingsByPane.has(paneIdx)) drawingsByPane.set(paneIdx, []);
|
|
1396
|
+
drawingsByPane.get(paneIdx)!.push(d);
|
|
1397
|
+
});
|
|
1398
|
+
drawingsByPane.forEach((paneDrawings) => {
|
|
1399
|
+
drawingSeriesUpdates.push({
|
|
1400
|
+
data: paneDrawings.map((d) => [
|
|
1401
|
+
d.points[0].timeIndex + this.dataIndexOffset,
|
|
1402
|
+
d.points[0].value,
|
|
1403
|
+
d.points[1].timeIndex + this.dataIndexOffset,
|
|
1404
|
+
d.points[1].value,
|
|
1405
|
+
]),
|
|
1406
|
+
});
|
|
1407
|
+
});
|
|
1408
|
+
|
|
1409
|
+
// 6. Merge update — preserves drag/interaction state
|
|
1410
|
+
const updateOption: any = {
|
|
1411
|
+
xAxis: currentOption.xAxis.map(() => ({ data: categoryData })),
|
|
1412
|
+
dataZoom: [
|
|
1413
|
+
{ start: newStart, end: newEnd },
|
|
1414
|
+
{ start: newStart, end: newEnd },
|
|
1415
|
+
],
|
|
1416
|
+
series: [
|
|
1417
|
+
{ data: coloredCandlestickData, markLine: candlestickSeries.markLine },
|
|
1418
|
+
...indicatorSeries.map((s) => {
|
|
1419
|
+
const update: any = { data: s.data };
|
|
1420
|
+
if (s.renderItem) update.renderItem = s.renderItem;
|
|
1421
|
+
return update;
|
|
1422
|
+
}),
|
|
1423
|
+
...drawingSeriesUpdates,
|
|
1424
|
+
],
|
|
1425
|
+
};
|
|
1426
|
+
this.chart.setOption(updateOption, { notMerge: false });
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
/**
|
|
1430
|
+
* Check if user scrolled near an edge (expand) or away from edges (contract).
|
|
1431
|
+
* Uses requestAnimationFrame to avoid cascading re-renders inside
|
|
1432
|
+
* the ECharts dataZoom event callback.
|
|
1433
|
+
*/
|
|
1434
|
+
private _checkEdgeAndExpand(): void {
|
|
1435
|
+
if (this._expandScheduled) return;
|
|
1436
|
+
|
|
1437
|
+
const zoomComp = (this.chart.getOption() as any)?.dataZoom?.find((dz: any) => dz.type === 'slider' || dz.type === 'inside');
|
|
1438
|
+
if (!zoomComp) return;
|
|
1439
|
+
|
|
1440
|
+
const paddingPoints = this._paddingPoints;
|
|
1137
1441
|
const dataLength = this.marketData.length;
|
|
1138
|
-
const
|
|
1139
|
-
|
|
1442
|
+
const totalLength = dataLength + 2 * paddingPoints;
|
|
1443
|
+
const startIdx = Math.round((zoomComp.start / 100) * totalLength);
|
|
1444
|
+
const endIdx = Math.round((zoomComp.end / 100) * totalLength);
|
|
1445
|
+
|
|
1446
|
+
// Count visible real candles (overlap between viewport and data range)
|
|
1447
|
+
const dataStart = paddingPoints;
|
|
1448
|
+
const dataEnd = paddingPoints + dataLength - 1;
|
|
1449
|
+
const visibleCandles = Math.max(0, Math.min(endIdx, dataEnd) - Math.max(startIdx, dataStart) + 1);
|
|
1450
|
+
|
|
1451
|
+
const nearLeftEdge = startIdx < this.LAZY_EDGE_THRESHOLD;
|
|
1452
|
+
const nearRightEdge = endIdx > totalLength - this.LAZY_EDGE_THRESHOLD;
|
|
1453
|
+
|
|
1454
|
+
// Don't expand when zoomed in very tight (fewer than 3 visible candles)
|
|
1455
|
+
if ((nearLeftEdge || nearRightEdge) && paddingPoints < this.LAZY_MAX_PADDING && visibleCandles >= 3) {
|
|
1456
|
+
this._expandScheduled = true;
|
|
1457
|
+
requestAnimationFrame(() => {
|
|
1458
|
+
this._expandScheduled = false;
|
|
1459
|
+
this._resizePadding(paddingPoints + this.LAZY_CHUNK_SIZE);
|
|
1460
|
+
});
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
// Contract if far from both edges and padding is larger than needed
|
|
1465
|
+
// Calculate how many padding bars are visible/near-visible on each side
|
|
1466
|
+
const leftPadUsed = Math.max(0, paddingPoints - startIdx);
|
|
1467
|
+
const rightPadUsed = Math.max(0, endIdx - (paddingPoints + dataLength - 1));
|
|
1468
|
+
const neededPadding = Math.max(
|
|
1469
|
+
leftPadUsed + this.LAZY_CHUNK_SIZE, // keep one chunk of buffer
|
|
1470
|
+
rightPadUsed + this.LAZY_CHUNK_SIZE,
|
|
1471
|
+
);
|
|
1472
|
+
|
|
1473
|
+
// Only contract if we have at least one full chunk of excess
|
|
1474
|
+
if (paddingPoints > neededPadding + this.LAZY_CHUNK_SIZE) {
|
|
1475
|
+
this._expandScheduled = true;
|
|
1476
|
+
requestAnimationFrame(() => {
|
|
1477
|
+
this._expandScheduled = false;
|
|
1478
|
+
this._resizePadding(neededPadding);
|
|
1479
|
+
});
|
|
1480
|
+
}
|
|
1140
1481
|
}
|
|
1141
1482
|
|
|
1142
1483
|
private render(): void {
|
|
@@ -1258,19 +1599,18 @@ export class QFChart implements ChartContext {
|
|
|
1258
1599
|
paddingPoints,
|
|
1259
1600
|
paddedOHLCVForShapes, // Pass padded OHLCV
|
|
1260
1601
|
layout.overlayYAxisMap, // Pass overlay Y-axis mapping
|
|
1261
|
-
layout.separatePaneYAxisOffset // Pass Y-axis offset for separate panes
|
|
1602
|
+
layout.separatePaneYAxisOffset, // Pass Y-axis offset for separate panes
|
|
1262
1603
|
);
|
|
1263
1604
|
|
|
1264
|
-
// Apply barColors
|
|
1605
|
+
// Apply barColors (TradingView: barcolor() only changes body fill, borders/wicks stay default)
|
|
1265
1606
|
candlestickSeries.data = candlestickSeries.data.map((candle: any, i: number) => {
|
|
1266
1607
|
if (barColors[i]) {
|
|
1608
|
+
const vals = candle.value || candle;
|
|
1267
1609
|
return {
|
|
1268
|
-
value:
|
|
1610
|
+
value: vals,
|
|
1269
1611
|
itemStyle: {
|
|
1270
1612
|
color: barColors[i],
|
|
1271
1613
|
color0: barColors[i],
|
|
1272
|
-
borderColor: barColors[i],
|
|
1273
|
-
borderColor0: barColors[i],
|
|
1274
1614
|
},
|
|
1275
1615
|
};
|
|
1276
1616
|
}
|
|
@@ -1278,7 +1618,20 @@ export class QFChart implements ChartContext {
|
|
|
1278
1618
|
});
|
|
1279
1619
|
|
|
1280
1620
|
// 3. Build Graphics
|
|
1281
|
-
const
|
|
1621
|
+
const overlayIndicators: { id: string; titleColor?: string }[] = [];
|
|
1622
|
+
this.indicators.forEach((ind, id) => {
|
|
1623
|
+
if (ind.paneIndex === 0) {
|
|
1624
|
+
overlayIndicators.push({ id, titleColor: ind.titleColor });
|
|
1625
|
+
}
|
|
1626
|
+
});
|
|
1627
|
+
const graphic = GraphicBuilder.build(
|
|
1628
|
+
layout,
|
|
1629
|
+
this.options,
|
|
1630
|
+
this.toggleIndicator.bind(this),
|
|
1631
|
+
this.isMainCollapsed,
|
|
1632
|
+
this.maximizedPaneId,
|
|
1633
|
+
overlayIndicators,
|
|
1634
|
+
);
|
|
1282
1635
|
|
|
1283
1636
|
// 4. Build Drawings Series (One Custom Series per Pane used)
|
|
1284
1637
|
const drawingsByPane = new Map<number, import('./types').DrawingElement[]>();
|
|
@@ -1302,254 +1655,34 @@ export class QFChart implements ChartContext {
|
|
|
1302
1655
|
const drawing = drawings[params.dataIndex];
|
|
1303
1656
|
if (!drawing) return;
|
|
1304
1657
|
|
|
1305
|
-
const
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
if (!start || !end) return;
|
|
1309
|
-
|
|
1310
|
-
// Coordinates are already in padded space, use directly
|
|
1311
|
-
const p1 = api.coord([start.timeIndex, start.value]);
|
|
1312
|
-
const p2 = api.coord([end.timeIndex, end.value]);
|
|
1313
|
-
|
|
1314
|
-
const isSelected = drawing.id === this.selectedDrawingId;
|
|
1315
|
-
|
|
1316
|
-
if (drawing.type === 'line') {
|
|
1317
|
-
return {
|
|
1318
|
-
type: 'group',
|
|
1319
|
-
children: [
|
|
1320
|
-
{
|
|
1321
|
-
type: 'line',
|
|
1322
|
-
name: 'line',
|
|
1323
|
-
shape: {
|
|
1324
|
-
x1: p1[0],
|
|
1325
|
-
y1: p1[1],
|
|
1326
|
-
x2: p2[0],
|
|
1327
|
-
y2: p2[1],
|
|
1328
|
-
},
|
|
1329
|
-
style: {
|
|
1330
|
-
stroke: drawing.style?.color || '#3b82f6',
|
|
1331
|
-
lineWidth: drawing.style?.lineWidth || 2,
|
|
1332
|
-
},
|
|
1333
|
-
},
|
|
1334
|
-
{
|
|
1335
|
-
type: 'circle',
|
|
1336
|
-
name: 'point-start',
|
|
1337
|
-
shape: { cx: p1[0], cy: p1[1], r: 4 },
|
|
1338
|
-
style: {
|
|
1339
|
-
fill: '#fff',
|
|
1340
|
-
stroke: drawing.style?.color || '#3b82f6',
|
|
1341
|
-
lineWidth: 1,
|
|
1342
|
-
opacity: isSelected ? 1 : 0, // Show if selected
|
|
1343
|
-
},
|
|
1344
|
-
},
|
|
1345
|
-
{
|
|
1346
|
-
type: 'circle',
|
|
1347
|
-
name: 'point-end',
|
|
1348
|
-
shape: { cx: p2[0], cy: p2[1], r: 4 },
|
|
1349
|
-
style: {
|
|
1350
|
-
fill: '#fff',
|
|
1351
|
-
stroke: drawing.style?.color || '#3b82f6',
|
|
1352
|
-
lineWidth: 1,
|
|
1353
|
-
opacity: isSelected ? 1 : 0, // Show if selected
|
|
1354
|
-
},
|
|
1355
|
-
},
|
|
1356
|
-
],
|
|
1357
|
-
};
|
|
1358
|
-
} else if (drawing.type === 'fibonacci') {
|
|
1359
|
-
const x1 = p1[0];
|
|
1360
|
-
const y1 = p1[1];
|
|
1361
|
-
const x2 = p2[0];
|
|
1362
|
-
const y2 = p2[1];
|
|
1363
|
-
|
|
1364
|
-
const startX = Math.min(x1, x2);
|
|
1365
|
-
const endX = Math.max(x1, x2);
|
|
1366
|
-
const width = endX - startX;
|
|
1367
|
-
const diffY = y2 - y1;
|
|
1368
|
-
|
|
1369
|
-
const levels = [0, 0.236, 0.382, 0.5, 0.618, 0.786, 1];
|
|
1370
|
-
const colors = ['#787b86', '#f44336', '#ff9800', '#4caf50', '#2196f3', '#00bcd4', '#787b86'];
|
|
1371
|
-
|
|
1372
|
-
const children: any[] = [];
|
|
1373
|
-
|
|
1374
|
-
// 1. Diagonal Line
|
|
1375
|
-
children.push({
|
|
1376
|
-
type: 'line',
|
|
1377
|
-
name: 'line', // Use 'line' name to enable dragging logic in DrawingEditor
|
|
1378
|
-
shape: { x1, y1, x2, y2 },
|
|
1379
|
-
style: {
|
|
1380
|
-
stroke: '#999',
|
|
1381
|
-
lineWidth: 1,
|
|
1382
|
-
lineDash: [4, 4],
|
|
1383
|
-
},
|
|
1384
|
-
});
|
|
1385
|
-
|
|
1386
|
-
// 2. Control Points (invisible by default)
|
|
1387
|
-
children.push({
|
|
1388
|
-
type: 'circle',
|
|
1389
|
-
name: 'point-start',
|
|
1390
|
-
shape: { cx: x1, cy: y1, r: 4 },
|
|
1391
|
-
style: {
|
|
1392
|
-
fill: '#fff',
|
|
1393
|
-
stroke: drawing.style?.color || '#3b82f6',
|
|
1394
|
-
lineWidth: 1,
|
|
1395
|
-
opacity: isSelected ? 1 : 0,
|
|
1396
|
-
},
|
|
1397
|
-
z: 100, // Ensure on top
|
|
1398
|
-
});
|
|
1399
|
-
children.push({
|
|
1400
|
-
type: 'circle',
|
|
1401
|
-
name: 'point-end',
|
|
1402
|
-
shape: { cx: x2, cy: y2, r: 4 },
|
|
1403
|
-
style: {
|
|
1404
|
-
fill: '#fff',
|
|
1405
|
-
stroke: drawing.style?.color || '#3b82f6',
|
|
1406
|
-
lineWidth: 1,
|
|
1407
|
-
opacity: isSelected ? 1 : 0,
|
|
1408
|
-
},
|
|
1409
|
-
z: 100,
|
|
1410
|
-
});
|
|
1411
|
-
|
|
1412
|
-
// 3. Levels and Backgrounds
|
|
1413
|
-
levels.forEach((level, index) => {
|
|
1414
|
-
const levelY = y2 - diffY * level;
|
|
1415
|
-
const color = colors[index % colors.length];
|
|
1416
|
-
|
|
1417
|
-
// Horizontal Line
|
|
1418
|
-
children.push({
|
|
1419
|
-
type: 'line',
|
|
1420
|
-
name: 'fib-line', // distinct name, maybe we don't want to drag by clicking these lines? or yes? 'line' triggers drag. 'fib-line' won't unless we update logic.
|
|
1421
|
-
// The user asked for "fib levels between start and end".
|
|
1422
|
-
shape: { x1: startX, y1: levelY, x2: endX, y2: levelY },
|
|
1423
|
-
style: { stroke: color, lineWidth: 1 },
|
|
1424
|
-
silent: true, // Make internal lines silent so clicks pass to background/diagonal?
|
|
1425
|
-
});
|
|
1426
|
-
|
|
1427
|
-
const startVal = drawing.points[0].value;
|
|
1428
|
-
const endVal = drawing.points[1].value;
|
|
1429
|
-
const valDiff = endVal - startVal;
|
|
1430
|
-
const price = endVal - valDiff * level;
|
|
1431
|
-
|
|
1432
|
-
children.push({
|
|
1433
|
-
type: 'text',
|
|
1434
|
-
style: {
|
|
1435
|
-
text: `${level} (${price.toFixed(2)})`,
|
|
1436
|
-
x: startX + 5,
|
|
1437
|
-
y: levelY - 10,
|
|
1438
|
-
fill: color,
|
|
1439
|
-
fontSize: 10,
|
|
1440
|
-
},
|
|
1441
|
-
silent: true,
|
|
1442
|
-
});
|
|
1443
|
-
|
|
1444
|
-
// Background
|
|
1445
|
-
if (index < levels.length - 1) {
|
|
1446
|
-
const nextLevel = levels[index + 1];
|
|
1447
|
-
const nextY = y2 - diffY * nextLevel;
|
|
1448
|
-
const rectH = Math.abs(nextY - levelY);
|
|
1449
|
-
const rectY = Math.min(levelY, nextY);
|
|
1450
|
-
|
|
1451
|
-
children.push({
|
|
1452
|
-
type: 'rect',
|
|
1453
|
-
shape: { x: startX, y: rectY, width, height: rectH },
|
|
1454
|
-
style: {
|
|
1455
|
-
fill: colors[(index + 1) % colors.length],
|
|
1456
|
-
opacity: 0.1,
|
|
1457
|
-
},
|
|
1458
|
-
silent: true, // Let clicks pass through?
|
|
1459
|
-
});
|
|
1460
|
-
}
|
|
1461
|
-
});
|
|
1658
|
+
const renderer = this.drawingRenderers.get(drawing.type);
|
|
1659
|
+
if (!renderer) return;
|
|
1462
1660
|
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
const levelY = y2 - diffY * level;
|
|
1468
|
-
const color = colors[index % colors.length];
|
|
1469
|
-
|
|
1470
|
-
linesAndText.push({
|
|
1471
|
-
type: 'line',
|
|
1472
|
-
shape: { x1: startX, y1: levelY, x2: endX, y2: levelY },
|
|
1473
|
-
style: { stroke: color, lineWidth: 1 },
|
|
1474
|
-
silent: true,
|
|
1475
|
-
});
|
|
1476
|
-
|
|
1477
|
-
const startVal = drawing.points[0].value;
|
|
1478
|
-
const endVal = drawing.points[1].value;
|
|
1479
|
-
const valDiff = endVal - startVal;
|
|
1480
|
-
const price = endVal - valDiff * level;
|
|
1481
|
-
|
|
1482
|
-
linesAndText.push({
|
|
1483
|
-
type: 'text',
|
|
1484
|
-
style: {
|
|
1485
|
-
text: `${level} (${price.toFixed(2)})`,
|
|
1486
|
-
x: startX + 5,
|
|
1487
|
-
y: levelY - 10,
|
|
1488
|
-
fill: color,
|
|
1489
|
-
fontSize: 10,
|
|
1490
|
-
},
|
|
1491
|
-
silent: true,
|
|
1492
|
-
});
|
|
1493
|
-
|
|
1494
|
-
if (index < levels.length - 1) {
|
|
1495
|
-
const nextLevel = levels[index + 1];
|
|
1496
|
-
const nextY = y2 - diffY * nextLevel;
|
|
1497
|
-
const rectH = Math.abs(nextY - levelY);
|
|
1498
|
-
const rectY = Math.min(levelY, nextY);
|
|
1499
|
-
|
|
1500
|
-
backgrounds.push({
|
|
1501
|
-
type: 'rect',
|
|
1502
|
-
name: 'line', // Enable dragging by clicking background!
|
|
1503
|
-
shape: { x: startX, y: rectY, width, height: rectH },
|
|
1504
|
-
style: {
|
|
1505
|
-
fill: colors[(index + 1) % colors.length],
|
|
1506
|
-
opacity: 0.1,
|
|
1507
|
-
},
|
|
1508
|
-
});
|
|
1509
|
-
}
|
|
1510
|
-
});
|
|
1661
|
+
const drawingOffset = this.dataIndexOffset;
|
|
1662
|
+
const pixelPoints = drawing.points.map(
|
|
1663
|
+
(p) => api.coord([p.timeIndex + drawingOffset, p.value]) as [number, number],
|
|
1664
|
+
);
|
|
1511
1665
|
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
type: 'line',
|
|
1519
|
-
name: 'line',
|
|
1520
|
-
shape: { x1, y1, x2, y2 },
|
|
1521
|
-
style: { stroke: '#999', lineWidth: 1, lineDash: [4, 4] },
|
|
1522
|
-
},
|
|
1523
|
-
{
|
|
1524
|
-
type: 'circle',
|
|
1525
|
-
name: 'point-start',
|
|
1526
|
-
shape: { cx: x1, cy: y1, r: 4 },
|
|
1527
|
-
style: {
|
|
1528
|
-
fill: '#fff',
|
|
1529
|
-
stroke: drawing.style?.color || '#3b82f6',
|
|
1530
|
-
lineWidth: 1,
|
|
1531
|
-
opacity: isSelected ? 1 : 0,
|
|
1532
|
-
},
|
|
1533
|
-
z: 100,
|
|
1534
|
-
},
|
|
1535
|
-
{
|
|
1536
|
-
type: 'circle',
|
|
1537
|
-
name: 'point-end',
|
|
1538
|
-
shape: { cx: x2, cy: y2, r: 4 },
|
|
1539
|
-
style: {
|
|
1540
|
-
fill: '#fff',
|
|
1541
|
-
stroke: drawing.style?.color || '#3b82f6',
|
|
1542
|
-
lineWidth: 1,
|
|
1543
|
-
opacity: isSelected ? 1 : 0,
|
|
1544
|
-
},
|
|
1545
|
-
z: 100,
|
|
1546
|
-
},
|
|
1547
|
-
],
|
|
1548
|
-
};
|
|
1549
|
-
}
|
|
1666
|
+
return renderer.render({
|
|
1667
|
+
drawing,
|
|
1668
|
+
pixelPoints,
|
|
1669
|
+
isSelected: drawing.id === this.selectedDrawingId,
|
|
1670
|
+
api,
|
|
1671
|
+
});
|
|
1550
1672
|
},
|
|
1551
|
-
data: drawings.map((d) =>
|
|
1552
|
-
|
|
1673
|
+
data: drawings.map((d) => {
|
|
1674
|
+
const row: number[] = [];
|
|
1675
|
+
d.points.forEach((p) => {
|
|
1676
|
+
row.push(p.timeIndex + this.dataIndexOffset, p.value);
|
|
1677
|
+
});
|
|
1678
|
+
return row;
|
|
1679
|
+
}),
|
|
1680
|
+
encode: (() => {
|
|
1681
|
+
const maxPoints = drawings.reduce((max, d) => Math.max(max, d.points.length), 0);
|
|
1682
|
+
const xDims = Array.from({ length: maxPoints }, (_, i) => i * 2);
|
|
1683
|
+
const yDims = Array.from({ length: maxPoints }, (_, i) => i * 2 + 1);
|
|
1684
|
+
return { x: xDims, y: yDims };
|
|
1685
|
+
})(),
|
|
1553
1686
|
z: 100,
|
|
1554
1687
|
silent: false,
|
|
1555
1688
|
});
|
|
@@ -1587,7 +1720,7 @@ export class QFChart implements ChartContext {
|
|
|
1587
1720
|
tables.forEach((t: any) => {
|
|
1588
1721
|
if (t && !t._deleted) {
|
|
1589
1722
|
// Tag table with its indicator's pane for correct positioning
|
|
1590
|
-
t._paneIndex =
|
|
1723
|
+
t._paneIndex = t.force_overlay ? 0 : indicator.paneIndex;
|
|
1591
1724
|
allTables.push(t);
|
|
1592
1725
|
}
|
|
1593
1726
|
});
|
|
@@ -1641,8 +1774,24 @@ export class QFChart implements ChartContext {
|
|
|
1641
1774
|
|
|
1642
1775
|
this.chart.setOption(option, true); // true = not merge, replace.
|
|
1643
1776
|
|
|
1644
|
-
//
|
|
1777
|
+
// Store base graphics (title, watermark, pane labels) for later re-use
|
|
1778
|
+
// in _renderTableOverlays so we can do a clean replaceMerge.
|
|
1779
|
+
this._baseGraphics = graphic;
|
|
1780
|
+
|
|
1781
|
+
// Render table graphics AFTER setOption so we can query the computed grid rects.
|
|
1782
|
+
// Uses replaceMerge to cleanly set all graphics without disrupting interactive components.
|
|
1645
1783
|
this._lastTables = allTables;
|
|
1646
|
-
|
|
1784
|
+
if (allTables.length > 0) {
|
|
1785
|
+
const tableGraphics = this._buildTableGraphics();
|
|
1786
|
+
if (tableGraphics.length > 0) {
|
|
1787
|
+
const allGraphics = [...graphic, ...tableGraphics];
|
|
1788
|
+
this.chart.setOption({ graphic: allGraphics }, { replaceMerge: ['graphic'] } as any);
|
|
1789
|
+
}
|
|
1790
|
+
} else {
|
|
1791
|
+
this._tableGraphicIds = [];
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
// Clear DOM overlays (legacy)
|
|
1795
|
+
TableOverlayRenderer.clearAll(this.overlayContainer);
|
|
1647
1796
|
}
|
|
1648
1797
|
}
|