@qfo/qfchart 0.7.3 → 0.8.0
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 +49 -2
- package/dist/qfchart.min.browser.js +18 -16
- package/dist/qfchart.min.es.js +18 -16
- package/package.json +1 -1
- package/src/QFChart.ts +372 -59
- package/src/components/GraphicBuilder.ts +284 -263
- package/src/components/LayoutManager.ts +33 -22
- package/src/components/SeriesBuilder.ts +104 -0
- 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/types.ts +11 -2
- package/src/utils/ColorUtils.ts +1 -1
package/src/QFChart.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { DrawingEditor } from './components/DrawingEditor';
|
|
|
10
10
|
import { EventBus } from './utils/EventBus';
|
|
11
11
|
import { AxisUtils } from './utils/AxisUtils';
|
|
12
12
|
import { TableOverlayRenderer } from './components/TableOverlayRenderer';
|
|
13
|
+
import { TableCanvasRenderer } from './components/TableCanvasRenderer';
|
|
13
14
|
|
|
14
15
|
export class QFChart implements ChartContext {
|
|
15
16
|
private chart: echarts.ECharts;
|
|
@@ -54,9 +55,11 @@ export class QFChart implements ChartContext {
|
|
|
54
55
|
const pGrid = this.chart.convertFromPixel({ gridIndex: i }, [point.x, point.y]);
|
|
55
56
|
|
|
56
57
|
if (pGrid) {
|
|
57
|
-
// Store
|
|
58
|
-
// This
|
|
59
|
-
|
|
58
|
+
// Store in real data indices (subtract padding offset).
|
|
59
|
+
// This makes drawing coordinates independent of lazy padding
|
|
60
|
+
// expansion — when _resizePadding() changes dataIndexOffset,
|
|
61
|
+
// stored coordinates stay valid without manual updating.
|
|
62
|
+
return { timeIndex: Math.round(pGrid[0]) - this.dataIndexOffset, value: pGrid[1], paneIndex: i };
|
|
60
63
|
}
|
|
61
64
|
}
|
|
62
65
|
}
|
|
@@ -64,8 +67,8 @@ export class QFChart implements ChartContext {
|
|
|
64
67
|
},
|
|
65
68
|
dataToPixel: (point: { timeIndex: number; value: number; paneIndex?: number }) => {
|
|
66
69
|
const paneIdx = point.paneIndex || 0;
|
|
67
|
-
//
|
|
68
|
-
const p = this.chart.convertToPixel({ gridIndex: paneIdx }, [point.timeIndex, point.value]);
|
|
70
|
+
// Convert real data index back to padded space for ECharts
|
|
71
|
+
const p = this.chart.convertToPixel({ gridIndex: paneIdx }, [point.timeIndex + this.dataIndexOffset, point.value]);
|
|
69
72
|
if (p) {
|
|
70
73
|
return { x: p[0], y: p[1] };
|
|
71
74
|
}
|
|
@@ -79,6 +82,12 @@ export class QFChart implements ChartContext {
|
|
|
79
82
|
private readonly defaultPadding = 0.0;
|
|
80
83
|
private padding: number;
|
|
81
84
|
private dataIndexOffset: number = 0; // Offset for phantom padding data
|
|
85
|
+
private _paddingPoints: number = 0; // Current symmetric padding (empty bars per side)
|
|
86
|
+
private readonly LAZY_MIN_PADDING = 5; // Always have a tiny buffer so edge scroll triggers
|
|
87
|
+
private readonly LAZY_MAX_PADDING = 500; // Hard cap per side
|
|
88
|
+
private readonly LAZY_CHUNK_SIZE = 50; // Bars added per expansion
|
|
89
|
+
private readonly LAZY_EDGE_THRESHOLD = 10; // Bars from edge to trigger
|
|
90
|
+
private _expandScheduled: boolean = false; // Debounce flag
|
|
82
91
|
|
|
83
92
|
// DOM Elements for Layout
|
|
84
93
|
private rootContainer: HTMLElement;
|
|
@@ -89,6 +98,9 @@ export class QFChart implements ChartContext {
|
|
|
89
98
|
private chartContainer: HTMLElement;
|
|
90
99
|
private overlayContainer: HTMLElement;
|
|
91
100
|
private _lastTables: any[] = [];
|
|
101
|
+
private _tableGraphicIds: string[] = []; // Track canvas table graphic IDs for cleanup
|
|
102
|
+
private _baseGraphics: any[] = []; // Non-table graphic elements (title, watermark, pane labels)
|
|
103
|
+
private _labelTooltipEl: HTMLElement | null = null; // Floating tooltip for label.set_tooltip()
|
|
92
104
|
|
|
93
105
|
// Pane drag-resize state
|
|
94
106
|
private _lastLayout: (LayoutResult & { overlayYAxisMap: Map<string, number>; separatePaneYAxisOffset: number }) | null = null;
|
|
@@ -195,7 +207,8 @@ export class QFChart implements ChartContext {
|
|
|
195
207
|
// Overlay container for table rendering (positioned above ECharts canvas)
|
|
196
208
|
this.chartContainer.style.position = 'relative';
|
|
197
209
|
this.overlayContainer = document.createElement('div');
|
|
198
|
-
this.overlayContainer.style.cssText =
|
|
210
|
+
this.overlayContainer.style.cssText =
|
|
211
|
+
'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:100;overflow:hidden;';
|
|
199
212
|
this.chartContainer.appendChild(this.overlayContainer);
|
|
200
213
|
|
|
201
214
|
this.pluginManager = new PluginManager(this, this.toolbarContainer);
|
|
@@ -209,22 +222,28 @@ export class QFChart implements ChartContext {
|
|
|
209
222
|
const triggerOn = this.options.databox?.triggerOn;
|
|
210
223
|
const position = this.options.databox?.position;
|
|
211
224
|
if (triggerOn === 'click' && position === 'floating') {
|
|
212
|
-
|
|
213
|
-
this.chart.dispatchAction({
|
|
214
|
-
type: 'hideTip',
|
|
215
|
-
});
|
|
225
|
+
this.chart.dispatchAction({ type: 'hideTip' });
|
|
216
226
|
}
|
|
227
|
+
|
|
228
|
+
// Lazy padding: check if user scrolled near an edge
|
|
229
|
+
this._checkEdgeAndExpand();
|
|
217
230
|
});
|
|
218
231
|
// @ts-ignore - ECharts event handler type mismatch
|
|
219
232
|
this.chart.on('finished', (params: any) => this.events.emit('chart:updated', params)); // General chart update
|
|
220
233
|
// @ts-ignore - ECharts ZRender event handler type mismatch
|
|
221
|
-
this.chart.getZr().on('mousedown', (params: any) => {
|
|
234
|
+
this.chart.getZr().on('mousedown', (params: any) => {
|
|
235
|
+
if (!this._paneDragState) this.events.emit('mouse:down', params);
|
|
236
|
+
});
|
|
222
237
|
// @ts-ignore - ECharts ZRender event handler type mismatch
|
|
223
|
-
this.chart.getZr().on('mousemove', (params: any) => {
|
|
238
|
+
this.chart.getZr().on('mousemove', (params: any) => {
|
|
239
|
+
if (!this._paneDragState) this.events.emit('mouse:move', params);
|
|
240
|
+
});
|
|
224
241
|
// @ts-ignore - ECharts ZRender event handler type mismatch
|
|
225
242
|
this.chart.getZr().on('mouseup', (params: any) => this.events.emit('mouse:up', params));
|
|
226
243
|
// @ts-ignore - ECharts ZRender event handler type mismatch
|
|
227
|
-
this.chart.getZr().on('click', (params: any) => {
|
|
244
|
+
this.chart.getZr().on('click', (params: any) => {
|
|
245
|
+
if (!this._paneDragState) this.events.emit('mouse:click', params);
|
|
246
|
+
});
|
|
228
247
|
|
|
229
248
|
const zr = this.chart.getZr();
|
|
230
249
|
const originalSetCursorStyle = zr.setCursorStyle;
|
|
@@ -275,9 +294,9 @@ export class QFChart implements ChartContext {
|
|
|
275
294
|
|
|
276
295
|
// ── Pane border drag-resize ────────────────────────────────
|
|
277
296
|
private bindPaneResizeEvents(): void {
|
|
278
|
-
const MIN_MAIN = 10;
|
|
297
|
+
const MIN_MAIN = 10; // minimum main pane height %
|
|
279
298
|
const MIN_INDICATOR = 5; // minimum indicator pane height %
|
|
280
|
-
const HIT_ZONE = 6;
|
|
299
|
+
const HIT_ZONE = 6; // hit-zone in pixels (±3px from boundary center)
|
|
281
300
|
|
|
282
301
|
const zr = this.chart.getZr();
|
|
283
302
|
|
|
@@ -598,6 +617,47 @@ export class QFChart implements ChartContext {
|
|
|
598
617
|
}
|
|
599
618
|
}
|
|
600
619
|
});
|
|
620
|
+
|
|
621
|
+
// --- Label Tooltip ---
|
|
622
|
+
// Create floating tooltip overlay for Pine Script label.set_tooltip()
|
|
623
|
+
this._labelTooltipEl = document.createElement('div');
|
|
624
|
+
this._labelTooltipEl.style.cssText =
|
|
625
|
+
'position:absolute;display:none;pointer-events:none;z-index:200;' +
|
|
626
|
+
'background:rgba(30,41,59,0.95);color:#fff;border:1px solid #475569;' +
|
|
627
|
+
'border-radius:4px;padding:6px 10px;font-size:12px;line-height:1.5;' +
|
|
628
|
+
'white-space:pre-wrap;max-width:350px;box-shadow:0 2px 8px rgba(0,0,0,0.3);' +
|
|
629
|
+
'font-family:' +
|
|
630
|
+
(this.options.fontFamily || 'sans-serif') +
|
|
631
|
+
';';
|
|
632
|
+
this.chartContainer.appendChild(this._labelTooltipEl);
|
|
633
|
+
|
|
634
|
+
// Show tooltip on scatter item hover (labels with tooltip text)
|
|
635
|
+
this.chart.on('mouseover', { seriesType: 'scatter' }, (params: any) => {
|
|
636
|
+
const tooltipText = params.data?._tooltipText;
|
|
637
|
+
if (!tooltipText || !this._labelTooltipEl) return;
|
|
638
|
+
|
|
639
|
+
this._labelTooltipEl.textContent = tooltipText;
|
|
640
|
+
this._labelTooltipEl.style.display = 'block';
|
|
641
|
+
|
|
642
|
+
// Position below the scatter point
|
|
643
|
+
const chartRect = this.chartContainer.getBoundingClientRect();
|
|
644
|
+
const event = params.event?.event;
|
|
645
|
+
if (event) {
|
|
646
|
+
const x = event.clientX - chartRect.left;
|
|
647
|
+
const y = event.clientY - chartRect.top;
|
|
648
|
+
// Show below and slightly left of cursor
|
|
649
|
+
const tipWidth = this._labelTooltipEl.offsetWidth;
|
|
650
|
+
const left = Math.min(x - tipWidth / 2, chartRect.width - tipWidth - 8);
|
|
651
|
+
this._labelTooltipEl.style.left = Math.max(4, left) + 'px';
|
|
652
|
+
this._labelTooltipEl.style.top = y + 18 + 'px';
|
|
653
|
+
}
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
this.chart.on('mouseout', { seriesType: 'scatter' }, () => {
|
|
657
|
+
if (this._labelTooltipEl) {
|
|
658
|
+
this._labelTooltipEl.style.display = 'none';
|
|
659
|
+
}
|
|
660
|
+
});
|
|
601
661
|
}
|
|
602
662
|
|
|
603
663
|
// --- Plugin System Integration ---
|
|
@@ -842,19 +902,20 @@ export class QFChart implements ChartContext {
|
|
|
842
902
|
paddingPoints,
|
|
843
903
|
paddedOHLCVForShapes, // Pass padded OHLCV data
|
|
844
904
|
layout.overlayYAxisMap, // Pass overlay Y-axis mapping
|
|
845
|
-
layout.separatePaneYAxisOffset // Pass Y-axis offset for separate panes
|
|
905
|
+
layout.separatePaneYAxisOffset, // Pass Y-axis offset for separate panes
|
|
846
906
|
);
|
|
847
907
|
|
|
848
908
|
// Apply barColors to candlestick data
|
|
909
|
+
// TradingView behavior: barcolor() only changes body fill; borders/wicks keep default colors (green/red)
|
|
849
910
|
const coloredCandlestickData = paddedCandlestickData.map((candle: any, i: number) => {
|
|
850
911
|
if (barColors[i]) {
|
|
912
|
+
const vals = candle.value || candle;
|
|
851
913
|
return {
|
|
852
|
-
value:
|
|
914
|
+
value: vals,
|
|
853
915
|
itemStyle: {
|
|
854
|
-
color: barColors[i],
|
|
855
|
-
color0: barColors[i],
|
|
856
|
-
borderColor
|
|
857
|
-
borderColor0: barColors[i],
|
|
916
|
+
color: barColors[i], // up-candle body fill
|
|
917
|
+
color0: barColors[i], // down-candle body fill
|
|
918
|
+
// borderColor/borderColor0 intentionally omitted → inherits series default (green/red)
|
|
858
919
|
},
|
|
859
920
|
};
|
|
860
921
|
}
|
|
@@ -871,16 +932,7 @@ export class QFChart implements ChartContext {
|
|
|
871
932
|
data: coloredCandlestickData,
|
|
872
933
|
markLine: candlestickSeries.markLine, // Ensure markLine is updated
|
|
873
934
|
},
|
|
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
|
-
}),
|
|
935
|
+
...indicatorSeries,
|
|
884
936
|
],
|
|
885
937
|
};
|
|
886
938
|
|
|
@@ -897,7 +949,7 @@ export class QFChart implements ChartContext {
|
|
|
897
949
|
tables.forEach((t: any) => {
|
|
898
950
|
if (t && !t._deleted) {
|
|
899
951
|
// Tag table with its indicator's pane for correct positioning
|
|
900
|
-
t._paneIndex =
|
|
952
|
+
t._paneIndex = t.force_overlay ? 0 : indicator.paneIndex;
|
|
901
953
|
allTables.push(t);
|
|
902
954
|
}
|
|
903
955
|
});
|
|
@@ -916,14 +968,23 @@ export class QFChart implements ChartContext {
|
|
|
916
968
|
// Stop existing timer
|
|
917
969
|
this.stopCountdown();
|
|
918
970
|
|
|
919
|
-
if (!this.options.lastPriceLine?.showCountdown ||
|
|
971
|
+
if (!this.options.lastPriceLine?.showCountdown || this.marketData.length === 0) {
|
|
920
972
|
return;
|
|
921
973
|
}
|
|
922
974
|
|
|
975
|
+
// Auto-detect interval from market data if not explicitly set
|
|
976
|
+
let interval = this.options.interval;
|
|
977
|
+
if (!interval && this.marketData.length >= 2) {
|
|
978
|
+
const last = this.marketData[this.marketData.length - 1];
|
|
979
|
+
const prev = this.marketData[this.marketData.length - 2];
|
|
980
|
+
interval = last.time - prev.time;
|
|
981
|
+
}
|
|
982
|
+
if (!interval) return;
|
|
983
|
+
|
|
923
984
|
const updateLabel = () => {
|
|
924
985
|
if (this.marketData.length === 0) return;
|
|
925
986
|
const lastBar = this.marketData[this.marketData.length - 1];
|
|
926
|
-
const nextCloseTime = lastBar.time +
|
|
987
|
+
const nextCloseTime = lastBar.time + interval!;
|
|
927
988
|
const now = Date.now();
|
|
928
989
|
const diff = nextCloseTime - now;
|
|
929
990
|
|
|
@@ -974,9 +1035,8 @@ export class QFChart implements ChartContext {
|
|
|
974
1035
|
if (this.options.yAxisLabelFormatter) {
|
|
975
1036
|
priceStr = this.options.yAxisLabelFormatter(price);
|
|
976
1037
|
} else {
|
|
977
|
-
const decimals =
|
|
978
|
-
? this.options.yAxisDecimalPlaces
|
|
979
|
-
: AxisUtils.autoDetectDecimals(this.marketData);
|
|
1038
|
+
const decimals =
|
|
1039
|
+
this.options.yAxisDecimalPlaces !== undefined ? this.options.yAxisDecimalPlaces : AxisUtils.autoDetectDecimals(this.marketData);
|
|
980
1040
|
priceStr = AxisUtils.formatValue(price, decimals);
|
|
981
1041
|
}
|
|
982
1042
|
|
|
@@ -1030,10 +1090,10 @@ export class QFChart implements ChartContext {
|
|
|
1030
1090
|
height?: number;
|
|
1031
1091
|
titleColor?: string;
|
|
1032
1092
|
controls?: { collapse?: boolean; maximize?: boolean };
|
|
1033
|
-
} = {}
|
|
1093
|
+
} = {},
|
|
1034
1094
|
): Indicator {
|
|
1035
1095
|
// Handle backward compatibility: prefer 'overlay' over 'isOverlay'
|
|
1036
|
-
const isOverlay = options.overlay !== undefined ? options.overlay : options.isOverlay ?? false;
|
|
1096
|
+
const isOverlay = options.overlay !== undefined ? options.overlay : (options.isOverlay ?? false);
|
|
1037
1097
|
let paneIndex = 0;
|
|
1038
1098
|
if (!isOverlay) {
|
|
1039
1099
|
// Find the next available pane index
|
|
@@ -1110,11 +1170,42 @@ export class QFChart implements ChartContext {
|
|
|
1110
1170
|
this._renderTableOverlays();
|
|
1111
1171
|
}
|
|
1112
1172
|
|
|
1113
|
-
|
|
1173
|
+
/**
|
|
1174
|
+
* Build table canvas graphic elements from the current _lastTables.
|
|
1175
|
+
* Must be called AFTER setOption so grid rects are available from ECharts.
|
|
1176
|
+
* Returns an array of ECharts graphic elements.
|
|
1177
|
+
*/
|
|
1178
|
+
private _buildTableGraphics(): any[] {
|
|
1114
1179
|
const model = this.chart.getModel() as any;
|
|
1115
|
-
const getGridRect = (paneIndex: number) =>
|
|
1116
|
-
|
|
1117
|
-
|
|
1180
|
+
const getGridRect = (paneIndex: number) => model.getComponent('grid', paneIndex)?.coordinateSystem?.getRect();
|
|
1181
|
+
const elements = TableCanvasRenderer.buildGraphicElements(this._lastTables, getGridRect);
|
|
1182
|
+
// Assign stable IDs for future merge/replace
|
|
1183
|
+
this._tableGraphicIds = [];
|
|
1184
|
+
for (let i = 0; i < elements.length; i++) {
|
|
1185
|
+
const id = `__qf_table_${i}`;
|
|
1186
|
+
elements[i].id = id;
|
|
1187
|
+
this._tableGraphicIds.push(id);
|
|
1188
|
+
}
|
|
1189
|
+
return elements;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/**
|
|
1193
|
+
* Render table overlays after a non-replacing setOption (updateData, resize).
|
|
1194
|
+
* Uses replaceMerge to cleanly replace all graphic elements without disrupting
|
|
1195
|
+
* other interactive components (dataZoom, tooltip, etc.).
|
|
1196
|
+
*/
|
|
1197
|
+
private _renderTableOverlays(): void {
|
|
1198
|
+
// Build new table graphics
|
|
1199
|
+
const tableGraphics = this._buildTableGraphics();
|
|
1200
|
+
|
|
1201
|
+
// Combine base graphics (title, watermark) + table graphics and replace all at once.
|
|
1202
|
+
// Using replaceMerge: ['graphic'] replaces ONLY the graphic component,
|
|
1203
|
+
// leaving dataZoom, tooltip, series etc. untouched.
|
|
1204
|
+
const allGraphics = [...this._baseGraphics, ...tableGraphics];
|
|
1205
|
+
this.chart.setOption({ graphic: allGraphics }, { replaceMerge: ['graphic'] } as any);
|
|
1206
|
+
|
|
1207
|
+
// Clear DOM overlays (legacy) — keep overlay container empty
|
|
1208
|
+
TableOverlayRenderer.clearAll(this.overlayContainer);
|
|
1118
1209
|
}
|
|
1119
1210
|
|
|
1120
1211
|
public destroy(): void {
|
|
@@ -1133,10 +1224,198 @@ export class QFChart implements ChartContext {
|
|
|
1133
1224
|
this.timeToIndex.set(k.time, index);
|
|
1134
1225
|
});
|
|
1135
1226
|
|
|
1136
|
-
//
|
|
1227
|
+
// Calculate initial padding from user-configured ratio
|
|
1137
1228
|
const dataLength = this.marketData.length;
|
|
1138
|
-
const
|
|
1139
|
-
|
|
1229
|
+
const initialPadding = Math.ceil(dataLength * this.padding);
|
|
1230
|
+
|
|
1231
|
+
// _paddingPoints can only grow (lazy expansion), never shrink below initial or minimum
|
|
1232
|
+
this._paddingPoints = Math.max(this._paddingPoints, initialPadding, this.LAZY_MIN_PADDING);
|
|
1233
|
+
this.dataIndexOffset = this._paddingPoints;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
/**
|
|
1237
|
+
* Expand symmetric padding to the given number of points per side.
|
|
1238
|
+
* No-op if newPaddingPoints <= current. Performs a full render() and
|
|
1239
|
+
* restores the viewport position so there is no visual jump.
|
|
1240
|
+
*/
|
|
1241
|
+
public expandPadding(newPaddingPoints: number): void {
|
|
1242
|
+
this._resizePadding(newPaddingPoints);
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/**
|
|
1246
|
+
* Resize symmetric padding to the given number of points per side.
|
|
1247
|
+
* Works for both growing and shrinking. Clamps to [min, max].
|
|
1248
|
+
* Uses merge-mode setOption to preserve drag/interaction state.
|
|
1249
|
+
*/
|
|
1250
|
+
private _resizePadding(newPaddingPoints: number): void {
|
|
1251
|
+
// Clamp to bounds
|
|
1252
|
+
const initialPadding = Math.ceil(this.marketData.length * this.padding);
|
|
1253
|
+
newPaddingPoints = Math.max(newPaddingPoints, initialPadding, this.LAZY_MIN_PADDING);
|
|
1254
|
+
newPaddingPoints = Math.min(newPaddingPoints, this.LAZY_MAX_PADDING);
|
|
1255
|
+
if (newPaddingPoints === this._paddingPoints) return;
|
|
1256
|
+
|
|
1257
|
+
// 1. Capture current viewport as absolute bar indices
|
|
1258
|
+
const oldPadding = this._paddingPoints;
|
|
1259
|
+
const oldTotal = this.marketData.length + 2 * oldPadding;
|
|
1260
|
+
const currentOption = this.chart.getOption() as any;
|
|
1261
|
+
const zoomComp = currentOption?.dataZoom?.find((dz: any) => dz.type === 'slider' || dz.type === 'inside');
|
|
1262
|
+
const oldStartIdx = zoomComp ? (zoomComp.start / 100) * oldTotal : 0;
|
|
1263
|
+
const oldEndIdx = zoomComp ? (zoomComp.end / 100) * oldTotal : oldTotal;
|
|
1264
|
+
|
|
1265
|
+
// 2. Update padding state (delta can be positive or negative)
|
|
1266
|
+
const delta = newPaddingPoints - oldPadding;
|
|
1267
|
+
this._paddingPoints = newPaddingPoints;
|
|
1268
|
+
this.dataIndexOffset = this._paddingPoints;
|
|
1269
|
+
const paddingPoints = this._paddingPoints;
|
|
1270
|
+
|
|
1271
|
+
// 3. Rebuild all data arrays with new padding
|
|
1272
|
+
const emptyCandle = { value: [NaN, NaN, NaN, NaN], itemStyle: { opacity: 0 } };
|
|
1273
|
+
const candlestickSeries = SeriesBuilder.buildCandlestickSeries(this.marketData, this.options);
|
|
1274
|
+
const paddedCandlestickData = [
|
|
1275
|
+
...Array(paddingPoints).fill(emptyCandle),
|
|
1276
|
+
...candlestickSeries.data,
|
|
1277
|
+
...Array(paddingPoints).fill(emptyCandle),
|
|
1278
|
+
];
|
|
1279
|
+
const categoryData = [
|
|
1280
|
+
...Array(paddingPoints).fill(''),
|
|
1281
|
+
...this.marketData.map((k) => new Date(k.time).toLocaleString()),
|
|
1282
|
+
...Array(paddingPoints).fill(''),
|
|
1283
|
+
];
|
|
1284
|
+
const paddedOHLCVForShapes = [...Array(paddingPoints).fill(null), ...this.marketData, ...Array(paddingPoints).fill(null)];
|
|
1285
|
+
|
|
1286
|
+
// Rebuild indicator series with new offset
|
|
1287
|
+
const layout = LayoutManager.calculate(
|
|
1288
|
+
this.chart.getHeight(),
|
|
1289
|
+
this.indicators,
|
|
1290
|
+
this.options,
|
|
1291
|
+
this.isMainCollapsed,
|
|
1292
|
+
this.maximizedPaneId,
|
|
1293
|
+
this.marketData,
|
|
1294
|
+
this._mainHeightOverride ?? undefined,
|
|
1295
|
+
);
|
|
1296
|
+
const { series: indicatorSeries, barColors } = SeriesBuilder.buildIndicatorSeries(
|
|
1297
|
+
this.indicators,
|
|
1298
|
+
this.timeToIndex,
|
|
1299
|
+
layout.paneLayout,
|
|
1300
|
+
categoryData.length,
|
|
1301
|
+
paddingPoints,
|
|
1302
|
+
paddedOHLCVForShapes,
|
|
1303
|
+
layout.overlayYAxisMap,
|
|
1304
|
+
layout.separatePaneYAxisOffset,
|
|
1305
|
+
);
|
|
1306
|
+
|
|
1307
|
+
// Apply barColors (TradingView: barcolor() only changes body fill, borders/wicks stay default)
|
|
1308
|
+
const coloredCandlestickData = paddedCandlestickData.map((candle: any, i: number) => {
|
|
1309
|
+
if (barColors[i]) {
|
|
1310
|
+
const vals = candle.value || candle;
|
|
1311
|
+
return {
|
|
1312
|
+
value: vals,
|
|
1313
|
+
itemStyle: {
|
|
1314
|
+
color: barColors[i],
|
|
1315
|
+
color0: barColors[i],
|
|
1316
|
+
},
|
|
1317
|
+
};
|
|
1318
|
+
}
|
|
1319
|
+
return candle;
|
|
1320
|
+
});
|
|
1321
|
+
|
|
1322
|
+
// 4. Calculate corrected zoom for new total length
|
|
1323
|
+
const newTotal = this.marketData.length + 2 * newPaddingPoints;
|
|
1324
|
+
const newStart = Math.max(0, ((oldStartIdx + delta) / newTotal) * 100);
|
|
1325
|
+
const newEnd = Math.min(100, ((oldEndIdx + delta) / newTotal) * 100);
|
|
1326
|
+
|
|
1327
|
+
// 5. Rebuild drawing series data with new offset so ECharts
|
|
1328
|
+
// viewport culling uses correct padded indices after expansion.
|
|
1329
|
+
const drawingSeriesUpdates: any[] = [];
|
|
1330
|
+
const drawingsByPane = new Map<number, import('./types').DrawingElement[]>();
|
|
1331
|
+
this.drawings.forEach((d) => {
|
|
1332
|
+
const paneIdx = d.paneIndex || 0;
|
|
1333
|
+
if (!drawingsByPane.has(paneIdx)) drawingsByPane.set(paneIdx, []);
|
|
1334
|
+
drawingsByPane.get(paneIdx)!.push(d);
|
|
1335
|
+
});
|
|
1336
|
+
drawingsByPane.forEach((paneDrawings) => {
|
|
1337
|
+
drawingSeriesUpdates.push({
|
|
1338
|
+
data: paneDrawings.map((d) => [
|
|
1339
|
+
d.points[0].timeIndex + this.dataIndexOffset,
|
|
1340
|
+
d.points[0].value,
|
|
1341
|
+
d.points[1].timeIndex + this.dataIndexOffset,
|
|
1342
|
+
d.points[1].value,
|
|
1343
|
+
]),
|
|
1344
|
+
});
|
|
1345
|
+
});
|
|
1346
|
+
|
|
1347
|
+
// 6. Merge update — preserves drag/interaction state
|
|
1348
|
+
const updateOption: any = {
|
|
1349
|
+
xAxis: currentOption.xAxis.map(() => ({ data: categoryData })),
|
|
1350
|
+
dataZoom: [
|
|
1351
|
+
{ start: newStart, end: newEnd },
|
|
1352
|
+
{ start: newStart, end: newEnd },
|
|
1353
|
+
],
|
|
1354
|
+
series: [
|
|
1355
|
+
{ data: coloredCandlestickData, markLine: candlestickSeries.markLine },
|
|
1356
|
+
...indicatorSeries.map((s) => {
|
|
1357
|
+
const update: any = { data: s.data };
|
|
1358
|
+
if (s.renderItem) update.renderItem = s.renderItem;
|
|
1359
|
+
return update;
|
|
1360
|
+
}),
|
|
1361
|
+
...drawingSeriesUpdates,
|
|
1362
|
+
],
|
|
1363
|
+
};
|
|
1364
|
+
this.chart.setOption(updateOption, { notMerge: false });
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
/**
|
|
1368
|
+
* Check if user scrolled near an edge (expand) or away from edges (contract).
|
|
1369
|
+
* Uses requestAnimationFrame to avoid cascading re-renders inside
|
|
1370
|
+
* the ECharts dataZoom event callback.
|
|
1371
|
+
*/
|
|
1372
|
+
private _checkEdgeAndExpand(): void {
|
|
1373
|
+
if (this._expandScheduled) return;
|
|
1374
|
+
|
|
1375
|
+
const zoomComp = (this.chart.getOption() as any)?.dataZoom?.find((dz: any) => dz.type === 'slider' || dz.type === 'inside');
|
|
1376
|
+
if (!zoomComp) return;
|
|
1377
|
+
|
|
1378
|
+
const paddingPoints = this._paddingPoints;
|
|
1379
|
+
const dataLength = this.marketData.length;
|
|
1380
|
+
const totalLength = dataLength + 2 * paddingPoints;
|
|
1381
|
+
const startIdx = Math.round((zoomComp.start / 100) * totalLength);
|
|
1382
|
+
const endIdx = Math.round((zoomComp.end / 100) * totalLength);
|
|
1383
|
+
|
|
1384
|
+
// Count visible real candles (overlap between viewport and data range)
|
|
1385
|
+
const dataStart = paddingPoints;
|
|
1386
|
+
const dataEnd = paddingPoints + dataLength - 1;
|
|
1387
|
+
const visibleCandles = Math.max(0, Math.min(endIdx, dataEnd) - Math.max(startIdx, dataStart) + 1);
|
|
1388
|
+
|
|
1389
|
+
const nearLeftEdge = startIdx < this.LAZY_EDGE_THRESHOLD;
|
|
1390
|
+
const nearRightEdge = endIdx > totalLength - this.LAZY_EDGE_THRESHOLD;
|
|
1391
|
+
|
|
1392
|
+
// Don't expand when zoomed in very tight (fewer than 3 visible candles)
|
|
1393
|
+
if ((nearLeftEdge || nearRightEdge) && paddingPoints < this.LAZY_MAX_PADDING && visibleCandles >= 3) {
|
|
1394
|
+
this._expandScheduled = true;
|
|
1395
|
+
requestAnimationFrame(() => {
|
|
1396
|
+
this._expandScheduled = false;
|
|
1397
|
+
this._resizePadding(paddingPoints + this.LAZY_CHUNK_SIZE);
|
|
1398
|
+
});
|
|
1399
|
+
return;
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
// Contract if far from both edges and padding is larger than needed
|
|
1403
|
+
// Calculate how many padding bars are visible/near-visible on each side
|
|
1404
|
+
const leftPadUsed = Math.max(0, paddingPoints - startIdx);
|
|
1405
|
+
const rightPadUsed = Math.max(0, endIdx - (paddingPoints + dataLength - 1));
|
|
1406
|
+
const neededPadding = Math.max(
|
|
1407
|
+
leftPadUsed + this.LAZY_CHUNK_SIZE, // keep one chunk of buffer
|
|
1408
|
+
rightPadUsed + this.LAZY_CHUNK_SIZE,
|
|
1409
|
+
);
|
|
1410
|
+
|
|
1411
|
+
// Only contract if we have at least one full chunk of excess
|
|
1412
|
+
if (paddingPoints > neededPadding + this.LAZY_CHUNK_SIZE) {
|
|
1413
|
+
this._expandScheduled = true;
|
|
1414
|
+
requestAnimationFrame(() => {
|
|
1415
|
+
this._expandScheduled = false;
|
|
1416
|
+
this._resizePadding(neededPadding);
|
|
1417
|
+
});
|
|
1418
|
+
}
|
|
1140
1419
|
}
|
|
1141
1420
|
|
|
1142
1421
|
private render(): void {
|
|
@@ -1258,19 +1537,18 @@ export class QFChart implements ChartContext {
|
|
|
1258
1537
|
paddingPoints,
|
|
1259
1538
|
paddedOHLCVForShapes, // Pass padded OHLCV
|
|
1260
1539
|
layout.overlayYAxisMap, // Pass overlay Y-axis mapping
|
|
1261
|
-
layout.separatePaneYAxisOffset // Pass Y-axis offset for separate panes
|
|
1540
|
+
layout.separatePaneYAxisOffset, // Pass Y-axis offset for separate panes
|
|
1262
1541
|
);
|
|
1263
1542
|
|
|
1264
|
-
// Apply barColors
|
|
1543
|
+
// Apply barColors (TradingView: barcolor() only changes body fill, borders/wicks stay default)
|
|
1265
1544
|
candlestickSeries.data = candlestickSeries.data.map((candle: any, i: number) => {
|
|
1266
1545
|
if (barColors[i]) {
|
|
1546
|
+
const vals = candle.value || candle;
|
|
1267
1547
|
return {
|
|
1268
|
-
value:
|
|
1548
|
+
value: vals,
|
|
1269
1549
|
itemStyle: {
|
|
1270
1550
|
color: barColors[i],
|
|
1271
1551
|
color0: barColors[i],
|
|
1272
|
-
borderColor: barColors[i],
|
|
1273
|
-
borderColor0: barColors[i],
|
|
1274
1552
|
},
|
|
1275
1553
|
};
|
|
1276
1554
|
}
|
|
@@ -1278,7 +1556,20 @@ export class QFChart implements ChartContext {
|
|
|
1278
1556
|
});
|
|
1279
1557
|
|
|
1280
1558
|
// 3. Build Graphics
|
|
1281
|
-
const
|
|
1559
|
+
const overlayIndicators: { id: string; titleColor?: string }[] = [];
|
|
1560
|
+
this.indicators.forEach((ind, id) => {
|
|
1561
|
+
if (ind.paneIndex === 0) {
|
|
1562
|
+
overlayIndicators.push({ id, titleColor: ind.titleColor });
|
|
1563
|
+
}
|
|
1564
|
+
});
|
|
1565
|
+
const graphic = GraphicBuilder.build(
|
|
1566
|
+
layout,
|
|
1567
|
+
this.options,
|
|
1568
|
+
this.toggleIndicator.bind(this),
|
|
1569
|
+
this.isMainCollapsed,
|
|
1570
|
+
this.maximizedPaneId,
|
|
1571
|
+
overlayIndicators,
|
|
1572
|
+
);
|
|
1282
1573
|
|
|
1283
1574
|
// 4. Build Drawings Series (One Custom Series per Pane used)
|
|
1284
1575
|
const drawingsByPane = new Map<number, import('./types').DrawingElement[]>();
|
|
@@ -1307,9 +1598,10 @@ export class QFChart implements ChartContext {
|
|
|
1307
1598
|
|
|
1308
1599
|
if (!start || !end) return;
|
|
1309
1600
|
|
|
1310
|
-
//
|
|
1311
|
-
const
|
|
1312
|
-
const
|
|
1601
|
+
// Convert real data indices to padded space for ECharts rendering
|
|
1602
|
+
const drawingOffset = this.dataIndexOffset;
|
|
1603
|
+
const p1 = api.coord([start.timeIndex + drawingOffset, start.value]);
|
|
1604
|
+
const p2 = api.coord([end.timeIndex + drawingOffset, end.value]);
|
|
1313
1605
|
|
|
1314
1606
|
const isSelected = drawing.id === this.selectedDrawingId;
|
|
1315
1607
|
|
|
@@ -1548,7 +1840,12 @@ export class QFChart implements ChartContext {
|
|
|
1548
1840
|
};
|
|
1549
1841
|
}
|
|
1550
1842
|
},
|
|
1551
|
-
data: drawings.map((d) => [
|
|
1843
|
+
data: drawings.map((d) => [
|
|
1844
|
+
d.points[0].timeIndex + this.dataIndexOffset,
|
|
1845
|
+
d.points[0].value,
|
|
1846
|
+
d.points[1].timeIndex + this.dataIndexOffset,
|
|
1847
|
+
d.points[1].value,
|
|
1848
|
+
]),
|
|
1552
1849
|
encode: { x: [0, 2], y: [1, 3] },
|
|
1553
1850
|
z: 100,
|
|
1554
1851
|
silent: false,
|
|
@@ -1587,7 +1884,7 @@ export class QFChart implements ChartContext {
|
|
|
1587
1884
|
tables.forEach((t: any) => {
|
|
1588
1885
|
if (t && !t._deleted) {
|
|
1589
1886
|
// Tag table with its indicator's pane for correct positioning
|
|
1590
|
-
t._paneIndex =
|
|
1887
|
+
t._paneIndex = t.force_overlay ? 0 : indicator.paneIndex;
|
|
1591
1888
|
allTables.push(t);
|
|
1592
1889
|
}
|
|
1593
1890
|
});
|
|
@@ -1641,8 +1938,24 @@ export class QFChart implements ChartContext {
|
|
|
1641
1938
|
|
|
1642
1939
|
this.chart.setOption(option, true); // true = not merge, replace.
|
|
1643
1940
|
|
|
1644
|
-
//
|
|
1941
|
+
// Store base graphics (title, watermark, pane labels) for later re-use
|
|
1942
|
+
// in _renderTableOverlays so we can do a clean replaceMerge.
|
|
1943
|
+
this._baseGraphics = graphic;
|
|
1944
|
+
|
|
1945
|
+
// Render table graphics AFTER setOption so we can query the computed grid rects.
|
|
1946
|
+
// Uses replaceMerge to cleanly set all graphics without disrupting interactive components.
|
|
1645
1947
|
this._lastTables = allTables;
|
|
1646
|
-
|
|
1948
|
+
if (allTables.length > 0) {
|
|
1949
|
+
const tableGraphics = this._buildTableGraphics();
|
|
1950
|
+
if (tableGraphics.length > 0) {
|
|
1951
|
+
const allGraphics = [...graphic, ...tableGraphics];
|
|
1952
|
+
this.chart.setOption({ graphic: allGraphics }, { replaceMerge: ['graphic'] } as any);
|
|
1953
|
+
}
|
|
1954
|
+
} else {
|
|
1955
|
+
this._tableGraphicIds = [];
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
// Clear DOM overlays (legacy)
|
|
1959
|
+
TableOverlayRenderer.clearAll(this.overlayContainer);
|
|
1647
1960
|
}
|
|
1648
1961
|
}
|