@qfo/qfchart 0.6.6 → 0.6.7

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/src/QFChart.ts CHANGED
@@ -1,1434 +1,1434 @@
1
- import * as echarts from 'echarts';
2
- import { OHLCV, IndicatorPlot, QFChartOptions, Indicator as IndicatorInterface, ChartContext, Plugin } from './types';
3
- import { Indicator } from './components/Indicator';
4
- import { LayoutManager } from './components/LayoutManager';
5
- import { SeriesBuilder } from './components/SeriesBuilder';
6
- import { GraphicBuilder } from './components/GraphicBuilder';
7
- import { TooltipFormatter } from './components/TooltipFormatter';
8
- import { PluginManager } from './components/PluginManager';
9
- import { DrawingEditor } from './components/DrawingEditor';
10
- import { EventBus } from './utils/EventBus';
11
- import { AxisUtils } from './utils/AxisUtils';
12
-
13
- export class QFChart implements ChartContext {
14
- private chart: echarts.ECharts;
15
- private options: QFChartOptions;
16
- private marketData: OHLCV[] = [];
17
- private indicators: Map<string, Indicator> = new Map();
18
- private timeToIndex: Map<number, number> = new Map();
19
- private pluginManager: PluginManager;
20
- private drawingEditor: DrawingEditor;
21
- public events: EventBus = new EventBus();
22
- private isMainCollapsed: boolean = false;
23
- private maximizedPaneId: string | null = null;
24
- private countdownInterval: any = null;
25
-
26
- private selectedDrawingId: string | null = null; // Track selected drawing
27
-
28
- // Drawing System
29
- private drawings: import('./types').DrawingElement[] = [];
30
-
31
- public coordinateConversion = {
32
- pixelToData: (point: { x: number; y: number }) => {
33
- // Find which grid/pane the point is in
34
- // We iterate through all panes (series indices usually match pane indices for base series)
35
- // Actually, we need to know how many panes there are.
36
- // We can use the layout logic or just check grid indices.
37
- // ECharts instance has getOption().
38
- const option = this.chart.getOption() as any;
39
- if (!option || !option.grid) return null;
40
-
41
- const gridCount = option.grid.length;
42
- for (let i = 0; i < gridCount; i++) {
43
- if (this.chart.containPixel({ gridIndex: i }, [point.x, point.y])) {
44
- // Found the pane
45
- const p = this.chart.convertFromPixel({ seriesIndex: i }, [point.x, point.y]);
46
- // Note: convertFromPixel might need seriesIndex or gridIndex depending on setup.
47
- // Using gridIndex in convertFromPixel is supported in newer ECharts but sometimes tricky.
48
- // Since we have one base series per pane (candlestick at 0, indicators at 1+),
49
- // assuming seriesIndex = gridIndex usually works if they are mapped 1:1.
50
- // Wait, candlestick is series 0. Indicators are subsequent series.
51
- // Series index != grid index necessarily.
52
- // BUT we can use { gridIndex: i } for convertFromPixel too!
53
- const pGrid = this.chart.convertFromPixel({ gridIndex: i }, [point.x, point.y]);
54
-
55
- if (pGrid) {
56
- // Store padded coordinates directly (don't subtract offset)
57
- // This ensures all coordinates are positive and within the valid padded range
58
- return { timeIndex: Math.round(pGrid[0]), value: pGrid[1], paneIndex: i };
59
- }
60
- }
61
- }
62
- return null;
63
- },
64
- dataToPixel: (point: { timeIndex: number; value: number; paneIndex?: number }) => {
65
- const paneIdx = point.paneIndex || 0;
66
- // Coordinates are already in padded space, so use directly
67
- const p = this.chart.convertToPixel({ gridIndex: paneIdx }, [point.timeIndex, point.value]);
68
- if (p) {
69
- return { x: p[0], y: p[1] };
70
- }
71
- return null;
72
- },
73
- };
74
-
75
- // Default colors and constants
76
- private readonly upColor: string = '#00da3c';
77
- private readonly downColor: string = '#ec0000';
78
- private readonly defaultPadding = 0.0;
79
- private padding: number;
80
- private dataIndexOffset: number = 0; // Offset for phantom padding data
81
-
82
- // DOM Elements for Layout
83
- private rootContainer: HTMLElement;
84
- private layoutContainer: HTMLElement;
85
- private toolbarContainer: HTMLElement; // New Toolbar
86
- private leftSidebar: HTMLElement;
87
- private rightSidebar: HTMLElement;
88
- private chartContainer: HTMLElement;
89
-
90
- constructor(container: HTMLElement, options: QFChartOptions = {}) {
91
- this.rootContainer = container;
92
- this.options = {
93
- title: 'Market',
94
- height: '600px',
95
- backgroundColor: '#1e293b',
96
- upColor: '#00da3c',
97
- downColor: '#ec0000',
98
- fontColor: '#cbd5e1',
99
- fontFamily: 'sans-serif',
100
- padding: 0.01,
101
- dataZoom: {
102
- visible: true,
103
- position: 'top',
104
- height: 6,
105
- },
106
- layout: {
107
- mainPaneHeight: '50%',
108
- gap: 13,
109
- },
110
- watermark: true,
111
- ...options,
112
- };
113
-
114
- if (this.options.upColor) this.upColor = this.options.upColor;
115
- if (this.options.downColor) this.downColor = this.options.downColor;
116
- this.padding = this.options.padding !== undefined ? this.options.padding : this.defaultPadding;
117
-
118
- if (this.options.height) {
119
- if (typeof this.options.height === 'number') {
120
- this.rootContainer.style.height = `${this.options.height}px`;
121
- } else {
122
- this.rootContainer.style.height = this.options.height;
123
- }
124
- }
125
-
126
- // Initialize DOM Layout
127
- this.rootContainer.innerHTML = '';
128
-
129
- // Layout Container (Flex Row)
130
- this.layoutContainer = document.createElement('div');
131
- this.layoutContainer.style.display = 'flex';
132
- this.layoutContainer.style.width = '100%';
133
- this.layoutContainer.style.height = '100%';
134
- this.layoutContainer.style.overflow = 'hidden';
135
- this.rootContainer.appendChild(this.layoutContainer);
136
-
137
- // Left Sidebar
138
- this.leftSidebar = document.createElement('div');
139
- this.leftSidebar.style.display = 'none';
140
- this.leftSidebar.style.width = '250px'; // Default width
141
- this.leftSidebar.style.flexShrink = '0';
142
- this.leftSidebar.style.overflowY = 'auto';
143
- this.leftSidebar.style.backgroundColor = this.options.backgroundColor || '#1e293b';
144
- this.leftSidebar.style.borderRight = '1px solid #334155';
145
- this.leftSidebar.style.padding = '10px';
146
- this.leftSidebar.style.boxSizing = 'border-box';
147
- this.leftSidebar.style.color = '#cbd5e1';
148
- this.leftSidebar.style.fontSize = '12px';
149
- this.leftSidebar.style.fontFamily = this.options.fontFamily || 'sans-serif';
150
- this.layoutContainer.appendChild(this.leftSidebar);
151
-
152
- // Toolbar Container
153
- this.toolbarContainer = document.createElement('div');
154
- this.layoutContainer.appendChild(this.toolbarContainer);
155
-
156
- // Chart Container
157
- this.chartContainer = document.createElement('div');
158
- this.chartContainer.style.flexGrow = '1';
159
- this.chartContainer.style.height = '100%';
160
- this.chartContainer.style.overflow = 'hidden';
161
- this.layoutContainer.appendChild(this.chartContainer);
162
-
163
- // Right Sidebar
164
- this.rightSidebar = document.createElement('div');
165
- this.rightSidebar.style.display = 'none';
166
- this.rightSidebar.style.width = '250px';
167
- this.rightSidebar.style.flexShrink = '0';
168
- this.rightSidebar.style.overflowY = 'auto';
169
- this.rightSidebar.style.backgroundColor = this.options.backgroundColor || '#1e293b';
170
- this.rightSidebar.style.borderLeft = '1px solid #334155';
171
- this.rightSidebar.style.padding = '10px';
172
- this.rightSidebar.style.boxSizing = 'border-box';
173
- this.rightSidebar.style.color = '#cbd5e1';
174
- this.rightSidebar.style.fontSize = '12px';
175
- this.rightSidebar.style.fontFamily = this.options.fontFamily || 'sans-serif';
176
- this.layoutContainer.appendChild(this.rightSidebar);
177
-
178
- this.chart = echarts.init(this.chartContainer);
179
- this.pluginManager = new PluginManager(this, this.toolbarContainer);
180
- this.drawingEditor = new DrawingEditor(this);
181
-
182
- // Bind global chart/ZRender events to the EventBus
183
- this.chart.on('dataZoom', (params: any) => {
184
- this.events.emit('chart:dataZoom', params);
185
-
186
- // Auto-hide tooltip when dragging chart if triggerOn is 'click' and position is 'floating'
187
- const triggerOn = this.options.databox?.triggerOn;
188
- const position = this.options.databox?.position;
189
- if (triggerOn === 'click' && position === 'floating') {
190
- // Hide tooltip by dispatching a hideTooltip action
191
- this.chart.dispatchAction({
192
- type: 'hideTip',
193
- });
194
- }
195
- });
196
- // @ts-ignore - ECharts event handler type mismatch
197
- this.chart.on('finished', (params: any) => this.events.emit('chart:updated', params)); // General chart update
198
- // @ts-ignore - ECharts ZRender event handler type mismatch
199
- this.chart.getZr().on('mousedown', (params: any) => this.events.emit('mouse:down', params));
200
- // @ts-ignore - ECharts ZRender event handler type mismatch
201
- this.chart.getZr().on('mousemove', (params: any) => this.events.emit('mouse:move', params));
202
- // @ts-ignore - ECharts ZRender event handler type mismatch
203
- this.chart.getZr().on('mouseup', (params: any) => this.events.emit('mouse:up', params));
204
- // @ts-ignore - ECharts ZRender event handler type mismatch
205
- this.chart.getZr().on('click', (params: any) => this.events.emit('mouse:click', params));
206
-
207
- const zr = this.chart.getZr();
208
- const originalSetCursorStyle = zr.setCursorStyle;
209
- zr.setCursorStyle = function (cursorStyle: string) {
210
- // Change 'grab' (default roam cursor) to 'crosshair' (more suitable for candlestick chart)
211
- if (cursorStyle === 'grab') {
212
- cursorStyle = 'crosshair';
213
- }
214
- // Call the original method with your modified style
215
- originalSetCursorStyle.call(this, cursorStyle);
216
- };
217
-
218
- // Bind Drawing Events
219
- this.bindDrawingEvents();
220
-
221
- window.addEventListener('resize', this.resize.bind(this));
222
-
223
- // Listen for fullscreen change to restore state if exited via ESC
224
- document.addEventListener('fullscreenchange', this.onFullscreenChange);
225
-
226
- // Keyboard listener for deletion
227
- document.addEventListener('keydown', this.onKeyDown);
228
- }
229
-
230
- private onKeyDown = (e: KeyboardEvent) => {
231
- if ((e.key === 'Delete' || e.key === 'Backspace') && this.selectedDrawingId) {
232
- this.removeDrawing(this.selectedDrawingId);
233
- this.selectedDrawingId = null;
234
- this.render();
235
- // Optional: emit deleted event here or in removeDrawing?
236
- // Since removeDrawing is generic, maybe better here if we want 'deleted by user' nuance.
237
- // But removeDrawing is called from other places too.
238
- }
239
- };
240
-
241
- private onFullscreenChange = () => {
242
- this.render();
243
- };
244
-
245
- private bindDrawingEvents() {
246
- let hideTimeout: any = null;
247
- let lastHoveredGroup: any = null;
248
-
249
- // Helper to get drawing info
250
- const getDrawingInfo = (params: any) => {
251
- if (!params || params.componentType !== 'series' || !params.seriesName?.startsWith('drawings')) {
252
- return null;
253
- }
254
-
255
- // Find the drawing
256
- const paneIndex = params.seriesIndex; // We can't rely on seriesIndex to find pane index easily as it shifts.
257
- // But we named the series "drawings-pane-{index}".
258
- const match = params.seriesName.match(/drawings-pane-(\d+)/);
259
- if (!match) return null;
260
-
261
- const paneIdx = parseInt(match[1]);
262
-
263
- // We stored drawings for this pane in render(), but here we access the flat list?
264
- // Wait, params.dataIndex is the index in the filtered array passed to that series.
265
- // We need to re-find the drawing or store metadata.
266
- // In render(), we map `drawingsByPane`.
267
-
268
- // Efficient way: Re-filter to get the specific drawing.
269
- // Assuming the order in render() is preserved.
270
- const paneDrawings = this.drawings.filter((d) => (d.paneIndex || 0) === paneIdx);
271
- const drawing = paneDrawings[params.dataIndex];
272
-
273
- if (!drawing) return null;
274
-
275
- // Check target for specific part (line or point)
276
- // ECharts event params.event.target is the graphic element
277
- const targetName = params.event?.target?.name; // 'line', 'point-start', 'point-end'
278
-
279
- return { drawing, targetName, paneIdx };
280
- };
281
-
282
- this.chart.on('mouseover', (params: any) => {
283
- const info = getDrawingInfo(params);
284
- if (!info) return;
285
-
286
- // Handle visibility of points
287
- const group = params.event?.target?.parent;
288
- if (group) {
289
- // If the drawing is selected, we don't want hover to mess with opacity
290
- // However, the user might be hovering a DIFFERENT drawing.
291
- // Let's check the drawing ID from 'info'.
292
- const isSelected = info.drawing.id === this.selectedDrawingId;
293
-
294
- if (hideTimeout) {
295
- clearTimeout(hideTimeout);
296
- hideTimeout = null;
297
- }
298
-
299
- // Show points if not selected (if selected, they are already visible)
300
- if (!isSelected) {
301
- group.children().forEach((child: any) => {
302
- if (child.name && child.name.startsWith('point')) {
303
- child.attr('style', { opacity: 1 });
304
- }
305
- });
306
- }
307
-
308
- // Handle switching groups
309
- if (lastHoveredGroup && lastHoveredGroup !== group) {
310
- // Check if last group belongs to the selected drawing?
311
- // We don't have easy access to the drawing ID of 'lastHoveredGroup' unless we stored it.
312
- // But we can just iterate and hide points.
313
- // Wait, if lastHoveredGroup IS the selected drawing, we should NOT hide points.
314
- // We need to know if lastHoveredGroup corresponds to selected drawing.
315
- // Storing 'lastHoveredDrawingId' would be better.
316
- // Simple fix: We rely on the render() logic which sets opacity: 1 for selected.
317
- // If we manually set opacity: 0 via ZRender attr, it might override the initial render state?
318
- // Yes, ZRender elements are persistent until re-render.
319
- // So we must be careful not to hide points of the selected drawing.
320
- // But we don't know the ID of lastHoveredGroup here easily.
321
- // Let's modify the hide logic to be safer.
322
- }
323
- lastHoveredGroup = group;
324
- }
325
-
326
- if (info.targetName === 'line') {
327
- this.events.emit('drawing:hover', {
328
- id: info.drawing.id,
329
- type: info.drawing.type,
330
- });
331
- // Set cursor
332
- this.chart.getZr().setCursorStyle('move');
333
- } else if (info.targetName?.startsWith('point')) {
334
- const pointIdx = info.targetName === 'point-start' ? 0 : 1;
335
- this.events.emit('drawing:point:hover', {
336
- id: info.drawing.id,
337
- pointIndex: pointIdx,
338
- });
339
- this.chart.getZr().setCursorStyle('pointer');
340
- }
341
- });
342
-
343
- this.chart.on('mouseout', (params: any) => {
344
- const info = getDrawingInfo(params);
345
- if (!info) return;
346
-
347
- // Hide points (with slight delay or check)
348
- const group = params.event?.target?.parent;
349
-
350
- // If selected, do not hide points
351
- if (info.drawing.id === this.selectedDrawingId) {
352
- // Keep points visible
353
- return;
354
- }
355
-
356
- // Delay hide to allow moving between siblings
357
- hideTimeout = setTimeout(() => {
358
- if (group) {
359
- // Check selection again inside timeout just in case
360
- if (this.selectedDrawingId === info.drawing.id) return;
361
-
362
- group.children().forEach((child: any) => {
363
- if (child.name && child.name.startsWith('point')) {
364
- child.attr('style', { opacity: 0 });
365
- }
366
- });
367
- }
368
- if (lastHoveredGroup === group) {
369
- lastHoveredGroup = null;
370
- }
371
- }, 50);
372
-
373
- if (info.targetName === 'line') {
374
- this.events.emit('drawing:mouseout', { id: info.drawing.id });
375
- } else if (info.targetName?.startsWith('point')) {
376
- const pointIdx = info.targetName === 'point-start' ? 0 : 1;
377
- this.events.emit('drawing:point:mouseout', {
378
- id: info.drawing.id,
379
- pointIndex: pointIdx,
380
- });
381
- }
382
- this.chart.getZr().setCursorStyle('default');
383
- });
384
-
385
- this.chart.on('mousedown', (params: any) => {
386
- const info = getDrawingInfo(params);
387
- if (!info) return;
388
-
389
- const event = params.event?.event || params.event;
390
- const x = event?.offsetX;
391
- const y = event?.offsetY;
392
-
393
- if (info.targetName === 'line') {
394
- this.events.emit('drawing:mousedown', {
395
- id: info.drawing.id,
396
- x,
397
- y,
398
- });
399
- } else if (info.targetName?.startsWith('point')) {
400
- const pointIdx = info.targetName === 'point-start' ? 0 : 1;
401
- this.events.emit('drawing:point:mousedown', {
402
- id: info.drawing.id,
403
- pointIndex: pointIdx,
404
- x,
405
- y,
406
- });
407
- }
408
- });
409
-
410
- this.chart.on('click', (params: any) => {
411
- const info = getDrawingInfo(params);
412
- if (!info) return;
413
-
414
- // Select Drawing logic
415
- if (this.selectedDrawingId !== info.drawing.id) {
416
- this.selectedDrawingId = info.drawing.id;
417
- this.events.emit('drawing:selected', { id: info.drawing.id });
418
- this.render(); // Re-render to update opacity permanent state
419
- }
420
-
421
- if (info.targetName === 'line') {
422
- this.events.emit('drawing:click', { id: info.drawing.id });
423
- } else if (info.targetName?.startsWith('point')) {
424
- const pointIdx = info.targetName === 'point-start' ? 0 : 1;
425
- this.events.emit('drawing:point:click', {
426
- id: info.drawing.id,
427
- pointIndex: pointIdx,
428
- });
429
- }
430
- });
431
-
432
- // Background click to deselect
433
- this.chart.getZr().on('click', (params: any) => {
434
- // If target is undefined or not part of a drawing series we know...
435
- if (!params.target) {
436
- if (this.selectedDrawingId) {
437
- this.events.emit('drawing:deselected', { id: this.selectedDrawingId });
438
- this.selectedDrawingId = null;
439
- this.render();
440
- }
441
- }
442
- });
443
- }
444
-
445
- // --- Plugin System Integration ---
446
-
447
- public getChart(): echarts.ECharts {
448
- return this.chart;
449
- }
450
-
451
- public getMarketData(): OHLCV[] {
452
- return this.marketData;
453
- }
454
-
455
- public getTimeToIndex(): Map<number, number> {
456
- return this.timeToIndex;
457
- }
458
-
459
- public getOptions(): QFChartOptions {
460
- return this.options;
461
- }
462
-
463
- public disableTools(): void {
464
- this.pluginManager.deactivatePlugin();
465
- }
466
-
467
- public registerPlugin(plugin: Plugin): void {
468
- this.pluginManager.register(plugin);
469
- }
470
-
471
- // --- Drawing System ---
472
-
473
- public addDrawing(drawing: import('./types').DrawingElement): void {
474
- this.drawings.push(drawing);
475
- this.render(); // Re-render to show new drawing
476
- }
477
-
478
- public removeDrawing(id: string): void {
479
- const index = this.drawings.findIndex((d) => d.id === id);
480
- if (index !== -1) {
481
- const drawing = this.drawings[index];
482
- this.drawings.splice(index, 1);
483
- this.events.emit('drawing:deleted', { id: drawing.id });
484
- this.render();
485
- }
486
- }
487
-
488
- public getDrawing(id: string): import('./types').DrawingElement | undefined {
489
- return this.drawings.find((d) => d.id === id);
490
- }
491
-
492
- public updateDrawing(drawing: import('./types').DrawingElement): void {
493
- const index = this.drawings.findIndex((d) => d.id === drawing.id);
494
- if (index !== -1) {
495
- this.drawings[index] = drawing;
496
- this.render();
497
- }
498
- }
499
-
500
- // --- Interaction Locking ---
501
-
502
- private isLocked: boolean = false;
503
- private lockedState: any = null;
504
-
505
- public lockChart(): void {
506
- if (this.isLocked) return;
507
- this.isLocked = true;
508
-
509
- const option = this.chart.getOption() as any;
510
-
511
- // Store current state to restore later if needed (though setOption merge handles most)
512
- // Actually, simply disabling interactions is enough.
513
-
514
- // We update the option to disable dataZoom and tooltip
515
- this.chart.setOption({
516
- dataZoom: option.dataZoom.map((dz: any) => ({ ...dz, disabled: true })),
517
- tooltip: { show: false }, // Hide tooltip during drag
518
- // We can also disable series interaction if needed, but custom series is handled by us.
519
- });
520
- }
521
-
522
- public unlockChart(): void {
523
- if (!this.isLocked) return;
524
- this.isLocked = false;
525
-
526
- const option = this.chart.getOption() as any;
527
-
528
- // Restore interactions
529
- // We assume dataZoom was enabled before. If not, we might re-enable it wrongly.
530
- // Ideally we should restore from 'options' or check the previous state.
531
- // Since 'render' rebuilds everything from 'this.options', we can just call render?
532
- // But render is expensive.
533
- // Better: Re-enable based on this.options.
534
-
535
- // Re-enable dataZoom
536
- const dzConfig = this.options.dataZoom || {};
537
- const dzVisible = dzConfig.visible ?? true;
538
-
539
- // We can map over current option.dataZoom and set disabled: false
540
- if (option.dataZoom) {
541
- this.chart.setOption({
542
- dataZoom: option.dataZoom.map((dz: any) => ({
543
- ...dz,
544
- disabled: false,
545
- })),
546
- tooltip: { show: true },
547
- });
548
- }
549
- }
550
-
551
- // --------------------------------
552
-
553
- public setZoom(start: number, end: number): void {
554
- this.chart.dispatchAction({
555
- type: 'dataZoom',
556
- start,
557
- end,
558
- });
559
- }
560
-
561
- public setMarketData(data: OHLCV[]): void {
562
- this.marketData = data;
563
- this.rebuildTimeIndex();
564
- this.render();
565
- }
566
-
567
- /**
568
- * Update market data incrementally without full re-render
569
- * Merges new/updated OHLCV data with existing data by timestamp
570
- *
571
- * @param data - Array of OHLCV data to merge
572
- *
573
- * @remarks
574
- * **Performance Optimization**: This method only triggers a chart update if the data array contains
575
- * new or modified bars. If an empty array is passed, no update occurs (expected behavior).
576
- *
577
- * **Usage Pattern for Updating Indicators**:
578
- * When updating both market data and indicators, follow this order:
579
- *
580
- * 1. Update indicator data first using `indicator.updateData(plots)`
581
- * 2. Then call `chart.updateData(newBars)` with the new/modified market data
582
- *
583
- * The chart update will trigger a re-render that includes the updated indicator data.
584
- *
585
- * **Important**: If you update indicator data without updating market data (e.g., recalculation
586
- * with same bars), you must still call `chart.updateData([...])` with at least one bar
587
- * to trigger the re-render. Calling with an empty array will NOT trigger an update.
588
- *
589
- * @example
590
- * ```typescript
591
- * // Step 1: Update indicator data
592
- * macdIndicator.updateData({
593
- * macd: { data: [{ time: 1234567890, value: 150 }], options: { style: 'line', color: '#2962FF' } }
594
- * });
595
- *
596
- * // Step 2: Update market data (triggers re-render with new indicator data)
597
- * chart.updateData([
598
- * { time: 1234567890, open: 100, high: 105, low: 99, close: 103, volume: 1000 }
599
- * ]);
600
- * ```
601
- *
602
- * @example
603
- * ```typescript
604
- * // If only updating existing bar (e.g., real-time tick updates):
605
- * const lastBar = { ...existingBar, close: newPrice, high: Math.max(existingBar.high, newPrice) };
606
- * chart.updateData([lastBar]); // Updates by timestamp
607
- * ```
608
- */
609
- public updateData(data: OHLCV[]): void {
610
- if (data.length === 0) return;
611
-
612
- // Build a map of existing data by time for O(1) lookups
613
- const existingTimeMap = new Map<number, OHLCV>();
614
- this.marketData.forEach((bar) => {
615
- existingTimeMap.set(bar.time, bar);
616
- });
617
-
618
- // Track if we added new data or only updated existing
619
- let hasNewData = false;
620
-
621
- // Merge new data
622
- data.forEach((bar) => {
623
- if (!existingTimeMap.has(bar.time)) {
624
- hasNewData = true;
625
- }
626
- existingTimeMap.set(bar.time, bar);
627
- });
628
-
629
- // Rebuild marketData array sorted by time
630
- this.marketData = Array.from(existingTimeMap.values()).sort((a, b) => a.time - b.time);
631
-
632
- // Update timeToIndex map
633
- this.rebuildTimeIndex();
634
-
635
- // Use pre-calculated padding points from rebuildTimeIndex
636
- const paddingPoints = this.dataIndexOffset;
637
-
638
- // Build candlestick data with padding
639
- const candlestickSeries = SeriesBuilder.buildCandlestickSeries(this.marketData, this.options);
640
- const emptyCandle = { value: [NaN, NaN, NaN, NaN], itemStyle: { opacity: 0 } };
641
- const paddedCandlestickData = [
642
- ...Array(paddingPoints).fill(emptyCandle),
643
- ...candlestickSeries.data,
644
- ...Array(paddingPoints).fill(emptyCandle),
645
- ];
646
-
647
- // Build category data with padding
648
- const categoryData = [
649
- ...Array(paddingPoints).fill(''),
650
- ...this.marketData.map((k) => new Date(k.time).toLocaleString()),
651
- ...Array(paddingPoints).fill(''),
652
- ];
653
-
654
- // Build indicator series data
655
- const currentOption = this.chart.getOption() as any;
656
- const layout = LayoutManager.calculate(
657
- this.chart.getHeight(),
658
- this.indicators,
659
- this.options,
660
- this.isMainCollapsed,
661
- this.maximizedPaneId,
662
- this.marketData
663
- );
664
-
665
- // Pass full padded candlestick data for shape positioning
666
- // But SeriesBuilder expects 'OHLCV[]', while paddedCandlestickData is array of arrays [open,close,low,high]
667
- // We need to pass the raw marketData but ALIGNED with padding?
668
- // Or better, pass the processed OHLCV array?
669
- // Let's pass the raw marketData, but SeriesBuilder needs to handle the padding internally or we pass padded data?
670
- // SeriesBuilder.buildIndicatorSeries iterates over 'totalDataLength' (which includes padding) and uses 'dataIndexOffset'.
671
- // So passing 'this.marketData' is not enough because index 0 in marketData corresponds to 'paddingPoints' index in chart.
672
- // We should pass an array that aligns with chart indices.
673
- // Let's reconstruct an array of objects {high, low} that includes padding.
674
-
675
- const paddedOHLCVForShapes = [...Array(paddingPoints).fill(null), ...this.marketData, ...Array(paddingPoints).fill(null)];
676
-
677
- const { series: indicatorSeries, barColors } = SeriesBuilder.buildIndicatorSeries(
678
- this.indicators,
679
- this.timeToIndex,
680
- layout.paneLayout,
681
- categoryData.length,
682
- paddingPoints,
683
- paddedOHLCVForShapes, // Pass padded OHLCV data
684
- layout.overlayYAxisMap, // Pass overlay Y-axis mapping
685
- layout.separatePaneYAxisOffset // Pass Y-axis offset for separate panes
686
- );
687
-
688
- // Apply barColors to candlestick data
689
- const coloredCandlestickData = paddedCandlestickData.map((candle: any, i: number) => {
690
- if (barColors[i]) {
691
- return {
692
- value: candle.value || candle,
693
- itemStyle: {
694
- color: barColors[i],
695
- color0: barColors[i],
696
- borderColor: barColors[i],
697
- borderColor0: barColors[i],
698
- },
699
- };
700
- }
701
- return candle;
702
- });
703
-
704
- // Update only the data arrays in the option, not the full config
705
- const updateOption: any = {
706
- xAxis: currentOption.xAxis.map((axis: any, index: number) => ({
707
- data: categoryData,
708
- })),
709
- series: [
710
- {
711
- data: coloredCandlestickData,
712
- markLine: candlestickSeries.markLine, // Ensure markLine is updated
713
- },
714
- ...indicatorSeries.map((s) => {
715
- const update: any = { data: s.data };
716
- // If the series has a renderItem function (custom series like background),
717
- // we MUST update it because it likely closes over variables (colorArray)
718
- // from the SeriesBuilder scope which have been recreated.
719
- if (s.renderItem) {
720
- update.renderItem = s.renderItem;
721
- }
722
- return update;
723
- }),
724
- ],
725
- };
726
-
727
- // Merge the update (don't replace entire config)
728
- this.chart.setOption(updateOption, { notMerge: false });
729
-
730
- // Update countdown if needed
731
- this.startCountdown();
732
- }
733
-
734
- private startCountdown() {
735
- // Stop existing timer
736
- this.stopCountdown();
737
-
738
- if (!this.options.lastPriceLine?.showCountdown || !this.options.interval || this.marketData.length === 0) {
739
- return;
740
- }
741
-
742
- const updateLabel = () => {
743
- if (this.marketData.length === 0) return;
744
- const lastBar = this.marketData[this.marketData.length - 1];
745
- const nextCloseTime = lastBar.time + (this.options.interval || 0);
746
- const now = Date.now();
747
- const diff = nextCloseTime - now;
748
-
749
- if (diff <= 0) {
750
- // Timer expired (bar closed), maybe wait for next update
751
- // Or show 00:00:00
752
- return;
753
- }
754
-
755
- // Format time
756
- const absDiff = Math.abs(diff);
757
- const hours = Math.floor(absDiff / 3600000);
758
- const minutes = Math.floor((absDiff % 3600000) / 60000);
759
- const seconds = Math.floor((absDiff % 60000) / 1000);
760
-
761
- const timeString = `${hours > 0 ? hours.toString().padStart(2, '0') + ':' : ''}${minutes.toString().padStart(2, '0')}:${seconds
762
- .toString()
763
- .padStart(2, '0')}`;
764
-
765
- // Update markLine label
766
- // We need to find the candlestick series index (usually 0)
767
- // But we can update by name if unique, or by index. SeriesBuilder sets name to options.title or 'Market'
768
- // Safest is to modify the option directly for series index 0 (if that's where candle is)
769
- // Or better, check current option
770
- const currentOption = this.chart.getOption() as any;
771
- if (!currentOption || !currentOption.series) return;
772
-
773
- // Find candlestick series (type 'candlestick')
774
- const candleSeriesIndex = currentOption.series.findIndex((s: any) => s.type === 'candlestick');
775
- if (candleSeriesIndex === -1) return;
776
-
777
- const candleSeries = currentOption.series[candleSeriesIndex];
778
- if (!candleSeries.markLine || !candleSeries.markLine.data || !candleSeries.markLine.data[0]) return;
779
-
780
- const markLineData = candleSeries.markLine.data[0];
781
- const currentFormatter = markLineData.label.formatter;
782
-
783
- // We need to preserve the price formatting logic.
784
- // But formatter is a function in the option we passed, but ECharts might have stored it?
785
- // ECharts getOption() returns the merged option. Functions are preserved.
786
- // We can wrap the formatter or just use the price value.
787
- // markLineData.yAxis is the price.
788
-
789
- const price = markLineData.yAxis;
790
- let priceStr = '';
791
-
792
- // Re-use formatting logic from options if possible, or auto-detect decimals
793
- if (this.options.yAxisLabelFormatter) {
794
- priceStr = this.options.yAxisLabelFormatter(price);
795
- } else {
796
- const decimals = this.options.yAxisDecimalPlaces !== undefined
797
- ? this.options.yAxisDecimalPlaces
798
- : AxisUtils.autoDetectDecimals(this.marketData);
799
- priceStr = AxisUtils.formatValue(price, decimals);
800
- }
801
-
802
- const labelText = `${priceStr}\n${timeString}`;
803
-
804
- // Reconstruct the markLine data to preserve styles (lineStyle, symbol, etc.)
805
- // We spread markLineData to keep everything (including lineStyle which defines color),
806
- // then overwrite the label to update the formatter/text.
807
-
808
- this.chart.setOption({
809
- series: [
810
- {
811
- name: this.options.title || 'Market',
812
- markLine: {
813
- data: [
814
- {
815
- ...markLineData, // Preserve lineStyle (color), symbol, yAxis, etc.
816
- label: {
817
- ...markLineData.label, // Preserve existing label styles including backgroundColor
818
- formatter: labelText, // Update only the text
819
- },
820
- },
821
- ],
822
- },
823
- },
824
- ],
825
- });
826
- };
827
-
828
- // Run immediately
829
- updateLabel();
830
-
831
- // Start interval
832
- this.countdownInterval = setInterval(updateLabel, 1000);
833
- }
834
-
835
- private stopCountdown() {
836
- if (this.countdownInterval) {
837
- clearInterval(this.countdownInterval);
838
- this.countdownInterval = null;
839
- }
840
- }
841
-
842
- public addIndicator(
843
- id: string,
844
- plots: { [name: string]: IndicatorPlot },
845
- options: {
846
- overlay?: boolean;
847
- /** @deprecated Use overlay instead */
848
- isOverlay?: boolean;
849
- height?: number;
850
- titleColor?: string;
851
- controls?: { collapse?: boolean; maximize?: boolean };
852
- } = {}
853
- ): Indicator {
854
- // Handle backward compatibility: prefer 'overlay' over 'isOverlay'
855
- const isOverlay = options.overlay !== undefined ? options.overlay : options.isOverlay ?? false;
856
- let paneIndex = 0;
857
- if (!isOverlay) {
858
- // Find the next available pane index
859
- // Start from 1, as 0 is the main chart
860
- let maxPaneIndex = 0;
861
- this.indicators.forEach((ind) => {
862
- if (ind.paneIndex > maxPaneIndex) {
863
- maxPaneIndex = ind.paneIndex;
864
- }
865
- });
866
- paneIndex = maxPaneIndex + 1;
867
- }
868
-
869
- // Create Indicator object
870
- const indicator = new Indicator(id, plots, paneIndex, {
871
- height: options.height,
872
- collapsed: false,
873
- titleColor: options.titleColor,
874
- controls: options.controls,
875
- });
876
-
877
- this.indicators.set(id, indicator);
878
- this.render();
879
- return indicator;
880
- }
881
-
882
- /** @deprecated Use addIndicator instead */
883
- public setIndicator(id: string, plot: IndicatorPlot, isOverlay: boolean = false): void {
884
- // Wrap single plot into the new structure (backward compatibility)
885
- this.addIndicator(id, { [id]: plot }, { overlay: isOverlay });
886
- }
887
-
888
- public removeIndicator(id: string): void {
889
- this.indicators.delete(id);
890
- this.render();
891
- }
892
-
893
- public toggleIndicator(id: string, action: 'collapse' | 'maximize' | 'fullscreen' = 'collapse'): void {
894
- if (action === 'fullscreen') {
895
- if (document.fullscreenElement) {
896
- document.exitFullscreen();
897
- } else {
898
- this.rootContainer.requestFullscreen();
899
- }
900
- return;
901
- }
902
-
903
- if (action === 'maximize') {
904
- if (this.maximizedPaneId === id) {
905
- // Restore
906
- this.maximizedPaneId = null;
907
- } else {
908
- // Maximize
909
- this.maximizedPaneId = id;
910
- }
911
- this.render();
912
- return;
913
- }
914
-
915
- if (id === 'main') {
916
- this.isMainCollapsed = !this.isMainCollapsed;
917
- this.render();
918
- return;
919
- }
920
- const indicator = this.indicators.get(id);
921
- if (indicator) {
922
- indicator.toggleCollapse();
923
- this.render();
924
- }
925
- }
926
-
927
- public resize(): void {
928
- this.chart.resize();
929
- }
930
-
931
- public destroy(): void {
932
- this.stopCountdown();
933
- window.removeEventListener('resize', this.resize.bind(this));
934
- document.removeEventListener('fullscreenchange', this.onFullscreenChange);
935
- document.removeEventListener('keydown', this.onKeyDown);
936
- this.pluginManager.deactivatePlugin(); // Cleanup active tool
937
- this.pluginManager.destroy(); // Cleanup tooltips
938
- this.chart.dispose();
939
- }
940
-
941
- private rebuildTimeIndex(): void {
942
- this.timeToIndex.clear();
943
- this.marketData.forEach((k, index) => {
944
- this.timeToIndex.set(k.time, index);
945
- });
946
-
947
- // Update dataIndexOffset whenever data changes
948
- const dataLength = this.marketData.length;
949
- const paddingPoints = Math.ceil(dataLength * this.padding);
950
- this.dataIndexOffset = paddingPoints;
951
- }
952
-
953
- private render(): void {
954
- if (this.marketData.length === 0) return;
955
-
956
- // Capture current zoom state before rebuilding options
957
- let currentZoomState: { start: number; end: number } | null = null;
958
- try {
959
- const currentOption = this.chart.getOption() as any;
960
- if (currentOption && currentOption.dataZoom && currentOption.dataZoom.length > 0) {
961
- // Find the slider or inside zoom component that controls the x-axis
962
- const zoomComponent = currentOption.dataZoom.find((dz: any) => dz.type === 'slider' || dz.type === 'inside');
963
- if (zoomComponent) {
964
- currentZoomState = {
965
- start: zoomComponent.start,
966
- end: zoomComponent.end,
967
- };
968
- }
969
- }
970
- } catch (e) {
971
- // Chart might not be initialized yet
972
- }
973
-
974
- // --- Sidebar Layout Management ---
975
- const tooltipPos = this.options.databox?.position; // undefined if not present
976
- const prevLeftDisplay = this.leftSidebar.style.display;
977
- const prevRightDisplay = this.rightSidebar.style.display;
978
-
979
- // If tooltipPos is undefined, we hide both sidebars and don't use them for tooltips.
980
- // We only show sidebars if position is explicitly 'left' or 'right'.
981
-
982
- const newLeftDisplay = tooltipPos === 'left' ? 'block' : 'none';
983
- const newRightDisplay = tooltipPos === 'right' ? 'block' : 'none';
984
-
985
- // Only resize if visibility changed to avoid unnecessary reflows/resizes
986
- if (prevLeftDisplay !== newLeftDisplay || prevRightDisplay !== newRightDisplay) {
987
- this.leftSidebar.style.display = newLeftDisplay;
988
- this.rightSidebar.style.display = newRightDisplay;
989
- this.chart.resize();
990
- }
991
- // ---------------------------------
992
-
993
- // Use pre-calculated padding points from rebuildTimeIndex
994
- const paddingPoints = this.dataIndexOffset;
995
-
996
- // Create extended category data with empty labels for padding
997
- const categoryData = [
998
- ...Array(paddingPoints).fill(''), // Left padding
999
- ...this.marketData.map((k) => new Date(k.time).toLocaleString()),
1000
- ...Array(paddingPoints).fill(''), // Right padding
1001
- ];
1002
-
1003
- // 1. Calculate Layout
1004
- const layout = LayoutManager.calculate(
1005
- this.chart.getHeight(),
1006
- this.indicators,
1007
- this.options,
1008
- this.isMainCollapsed,
1009
- this.maximizedPaneId,
1010
- this.marketData
1011
- );
1012
-
1013
- // Convert user-provided dataZoom start/end to account for padding
1014
- // User's start/end refer to real data (0% = start of real data, 100% = end of real data)
1015
- // We need to convert to padded data coordinates
1016
- if (!currentZoomState && layout.dataZoom && this.marketData.length > 0) {
1017
- const realDataLength = this.marketData.length;
1018
- const totalLength = categoryData.length; // includes padding on both sides
1019
- const paddingRatio = paddingPoints / totalLength;
1020
- const dataRatio = realDataLength / totalLength;
1021
-
1022
- layout.dataZoom.forEach((dz) => {
1023
- // Convert user's start/end (0-100 referring to real data) to actual start/end (0-100 referring to padded data)
1024
- if (dz.start !== undefined) {
1025
- // User's start% of real data -> actual position in padded data
1026
- const userStartFraction = dz.start / 100;
1027
- const actualStartFraction = paddingRatio + userStartFraction * dataRatio;
1028
- dz.start = actualStartFraction * 100;
1029
- }
1030
- if (dz.end !== undefined) {
1031
- // User's end% of real data -> actual position in padded data
1032
- const userEndFraction = dz.end / 100;
1033
- const actualEndFraction = paddingRatio + userEndFraction * dataRatio;
1034
- dz.end = actualEndFraction * 100;
1035
- }
1036
- });
1037
- }
1038
-
1039
- // Apply preserved zoom state if available (this overrides the conversion above)
1040
- if (currentZoomState && layout.dataZoom) {
1041
- layout.dataZoom.forEach((dz) => {
1042
- dz.start = currentZoomState!.start;
1043
- dz.end = currentZoomState!.end;
1044
- });
1045
- }
1046
-
1047
- // Patch X-Axis with extended data
1048
- layout.xAxis.forEach((axis) => {
1049
- axis.data = categoryData;
1050
- axis.boundaryGap = false; // No additional gap needed, we have phantom data
1051
- });
1052
-
1053
- // 2. Build Series with phantom data padding
1054
- const candlestickSeries = SeriesBuilder.buildCandlestickSeries(this.marketData, this.options);
1055
- // Extend candlestick data with empty objects (not null) to avoid rendering errors
1056
- const emptyCandle = { value: [NaN, NaN, NaN, NaN], itemStyle: { opacity: 0 } };
1057
- candlestickSeries.data = [...Array(paddingPoints).fill(emptyCandle), ...candlestickSeries.data, ...Array(paddingPoints).fill(emptyCandle)];
1058
-
1059
- // Build array of OHLCV aligned with indices for shape positioning
1060
- const paddedOHLCVForShapes = [...Array(paddingPoints).fill(null), ...this.marketData, ...Array(paddingPoints).fill(null)];
1061
-
1062
- const { series: indicatorSeries, barColors } = SeriesBuilder.buildIndicatorSeries(
1063
- this.indicators,
1064
- this.timeToIndex,
1065
- layout.paneLayout,
1066
- categoryData.length,
1067
- paddingPoints,
1068
- paddedOHLCVForShapes, // Pass padded OHLCV
1069
- layout.overlayYAxisMap, // Pass overlay Y-axis mapping
1070
- layout.separatePaneYAxisOffset // Pass Y-axis offset for separate panes
1071
- );
1072
-
1073
- // Apply barColors to candlestick data
1074
- candlestickSeries.data = candlestickSeries.data.map((candle: any, i: number) => {
1075
- if (barColors[i]) {
1076
- return {
1077
- value: candle.value || candle,
1078
- itemStyle: {
1079
- color: barColors[i],
1080
- color0: barColors[i],
1081
- borderColor: barColors[i],
1082
- borderColor0: barColors[i],
1083
- },
1084
- };
1085
- }
1086
- return candle;
1087
- });
1088
-
1089
- // 3. Build Graphics
1090
- const graphic = GraphicBuilder.build(layout, this.options, this.toggleIndicator.bind(this), this.isMainCollapsed, this.maximizedPaneId);
1091
-
1092
- // 4. Build Drawings Series (One Custom Series per Pane used)
1093
- const drawingsByPane = new Map<number, import('./types').DrawingElement[]>();
1094
- this.drawings.forEach((d) => {
1095
- const paneIdx = d.paneIndex || 0;
1096
- if (!drawingsByPane.has(paneIdx)) {
1097
- drawingsByPane.set(paneIdx, []);
1098
- }
1099
- drawingsByPane.get(paneIdx)!.push(d);
1100
- });
1101
-
1102
- const drawingSeriesList: any[] = [];
1103
- drawingsByPane.forEach((drawings, paneIndex) => {
1104
- drawingSeriesList.push({
1105
- type: 'custom',
1106
- name: `drawings-pane-${paneIndex}`,
1107
- xAxisIndex: paneIndex,
1108
- yAxisIndex: paneIndex,
1109
- clip: true,
1110
- renderItem: (params: any, api: any) => {
1111
- const drawing = drawings[params.dataIndex];
1112
- if (!drawing) return;
1113
-
1114
- const start = drawing.points[0];
1115
- const end = drawing.points[1];
1116
-
1117
- if (!start || !end) return;
1118
-
1119
- // Coordinates are already in padded space, use directly
1120
- const p1 = api.coord([start.timeIndex, start.value]);
1121
- const p2 = api.coord([end.timeIndex, end.value]);
1122
-
1123
- const isSelected = drawing.id === this.selectedDrawingId;
1124
-
1125
- if (drawing.type === 'line') {
1126
- return {
1127
- type: 'group',
1128
- children: [
1129
- {
1130
- type: 'line',
1131
- name: 'line',
1132
- shape: {
1133
- x1: p1[0],
1134
- y1: p1[1],
1135
- x2: p2[0],
1136
- y2: p2[1],
1137
- },
1138
- style: {
1139
- stroke: drawing.style?.color || '#3b82f6',
1140
- lineWidth: drawing.style?.lineWidth || 2,
1141
- },
1142
- },
1143
- {
1144
- type: 'circle',
1145
- name: 'point-start',
1146
- shape: { cx: p1[0], cy: p1[1], r: 4 },
1147
- style: {
1148
- fill: '#fff',
1149
- stroke: drawing.style?.color || '#3b82f6',
1150
- lineWidth: 1,
1151
- opacity: isSelected ? 1 : 0, // Show if selected
1152
- },
1153
- },
1154
- {
1155
- type: 'circle',
1156
- name: 'point-end',
1157
- shape: { cx: p2[0], cy: p2[1], r: 4 },
1158
- style: {
1159
- fill: '#fff',
1160
- stroke: drawing.style?.color || '#3b82f6',
1161
- lineWidth: 1,
1162
- opacity: isSelected ? 1 : 0, // Show if selected
1163
- },
1164
- },
1165
- ],
1166
- };
1167
- } else if (drawing.type === 'fibonacci') {
1168
- const x1 = p1[0];
1169
- const y1 = p1[1];
1170
- const x2 = p2[0];
1171
- const y2 = p2[1];
1172
-
1173
- const startX = Math.min(x1, x2);
1174
- const endX = Math.max(x1, x2);
1175
- const width = endX - startX;
1176
- const diffY = y2 - y1;
1177
-
1178
- const levels = [0, 0.236, 0.382, 0.5, 0.618, 0.786, 1];
1179
- const colors = ['#787b86', '#f44336', '#ff9800', '#4caf50', '#2196f3', '#00bcd4', '#787b86'];
1180
-
1181
- const children: any[] = [];
1182
-
1183
- // 1. Diagonal Line
1184
- children.push({
1185
- type: 'line',
1186
- name: 'line', // Use 'line' name to enable dragging logic in DrawingEditor
1187
- shape: { x1, y1, x2, y2 },
1188
- style: {
1189
- stroke: '#999',
1190
- lineWidth: 1,
1191
- lineDash: [4, 4],
1192
- },
1193
- });
1194
-
1195
- // 2. Control Points (invisible by default)
1196
- children.push({
1197
- type: 'circle',
1198
- name: 'point-start',
1199
- shape: { cx: x1, cy: y1, r: 4 },
1200
- style: {
1201
- fill: '#fff',
1202
- stroke: drawing.style?.color || '#3b82f6',
1203
- lineWidth: 1,
1204
- opacity: isSelected ? 1 : 0,
1205
- },
1206
- z: 100, // Ensure on top
1207
- });
1208
- children.push({
1209
- type: 'circle',
1210
- name: 'point-end',
1211
- shape: { cx: x2, cy: y2, r: 4 },
1212
- style: {
1213
- fill: '#fff',
1214
- stroke: drawing.style?.color || '#3b82f6',
1215
- lineWidth: 1,
1216
- opacity: isSelected ? 1 : 0,
1217
- },
1218
- z: 100,
1219
- });
1220
-
1221
- // 3. Levels and Backgrounds
1222
- levels.forEach((level, index) => {
1223
- const levelY = y2 - diffY * level;
1224
- const color = colors[index % colors.length];
1225
-
1226
- // Horizontal Line
1227
- children.push({
1228
- type: 'line',
1229
- 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.
1230
- // The user asked for "fib levels between start and end".
1231
- shape: { x1: startX, y1: levelY, x2: endX, y2: levelY },
1232
- style: { stroke: color, lineWidth: 1 },
1233
- silent: true, // Make internal lines silent so clicks pass to background/diagonal?
1234
- });
1235
-
1236
- const startVal = drawing.points[0].value;
1237
- const endVal = drawing.points[1].value;
1238
- const valDiff = endVal - startVal;
1239
- const price = endVal - valDiff * level;
1240
-
1241
- children.push({
1242
- type: 'text',
1243
- style: {
1244
- text: `${level} (${price.toFixed(2)})`,
1245
- x: startX + 5,
1246
- y: levelY - 10,
1247
- fill: color,
1248
- fontSize: 10,
1249
- },
1250
- silent: true,
1251
- });
1252
-
1253
- // Background
1254
- if (index < levels.length - 1) {
1255
- const nextLevel = levels[index + 1];
1256
- const nextY = y2 - diffY * nextLevel;
1257
- const rectH = Math.abs(nextY - levelY);
1258
- const rectY = Math.min(levelY, nextY);
1259
-
1260
- children.push({
1261
- type: 'rect',
1262
- shape: { x: startX, y: rectY, width, height: rectH },
1263
- style: {
1264
- fill: colors[(index + 1) % colors.length],
1265
- opacity: 0.1,
1266
- },
1267
- silent: true, // Let clicks pass through?
1268
- });
1269
- }
1270
- });
1271
-
1272
- const backgrounds: any[] = [];
1273
- const linesAndText: any[] = [];
1274
-
1275
- levels.forEach((level, index) => {
1276
- const levelY = y2 - diffY * level;
1277
- const color = colors[index % colors.length];
1278
-
1279
- linesAndText.push({
1280
- type: 'line',
1281
- shape: { x1: startX, y1: levelY, x2: endX, y2: levelY },
1282
- style: { stroke: color, lineWidth: 1 },
1283
- silent: true,
1284
- });
1285
-
1286
- const startVal = drawing.points[0].value;
1287
- const endVal = drawing.points[1].value;
1288
- const valDiff = endVal - startVal;
1289
- const price = endVal - valDiff * level;
1290
-
1291
- linesAndText.push({
1292
- type: 'text',
1293
- style: {
1294
- text: `${level} (${price.toFixed(2)})`,
1295
- x: startX + 5,
1296
- y: levelY - 10,
1297
- fill: color,
1298
- fontSize: 10,
1299
- },
1300
- silent: true,
1301
- });
1302
-
1303
- if (index < levels.length - 1) {
1304
- const nextLevel = levels[index + 1];
1305
- const nextY = y2 - diffY * nextLevel;
1306
- const rectH = Math.abs(nextY - levelY);
1307
- const rectY = Math.min(levelY, nextY);
1308
-
1309
- backgrounds.push({
1310
- type: 'rect',
1311
- name: 'line', // Enable dragging by clicking background!
1312
- shape: { x: startX, y: rectY, width, height: rectH },
1313
- style: {
1314
- fill: colors[(index + 1) % colors.length],
1315
- opacity: 0.1,
1316
- },
1317
- });
1318
- }
1319
- });
1320
-
1321
- return {
1322
- type: 'group',
1323
- children: [
1324
- ...backgrounds,
1325
- ...linesAndText,
1326
- {
1327
- type: 'line',
1328
- name: 'line',
1329
- shape: { x1, y1, x2, y2 },
1330
- style: { stroke: '#999', lineWidth: 1, lineDash: [4, 4] },
1331
- },
1332
- {
1333
- type: 'circle',
1334
- name: 'point-start',
1335
- shape: { cx: x1, cy: y1, r: 4 },
1336
- style: {
1337
- fill: '#fff',
1338
- stroke: drawing.style?.color || '#3b82f6',
1339
- lineWidth: 1,
1340
- opacity: isSelected ? 1 : 0,
1341
- },
1342
- z: 100,
1343
- },
1344
- {
1345
- type: 'circle',
1346
- name: 'point-end',
1347
- shape: { cx: x2, cy: y2, r: 4 },
1348
- style: {
1349
- fill: '#fff',
1350
- stroke: drawing.style?.color || '#3b82f6',
1351
- lineWidth: 1,
1352
- opacity: isSelected ? 1 : 0,
1353
- },
1354
- z: 100,
1355
- },
1356
- ],
1357
- };
1358
- }
1359
- },
1360
- data: drawings.map((d) => [d.points[0].timeIndex, d.points[0].value, d.points[1].timeIndex, d.points[1].value]),
1361
- z: 100,
1362
- silent: false,
1363
- });
1364
- });
1365
-
1366
- // 5. Tooltip Formatter
1367
- const tooltipFormatter = (params: any[]) => {
1368
- const html = TooltipFormatter.format(params, this.options);
1369
- const mode = this.options.databox?.position; // undefined if not present
1370
-
1371
- if (mode === 'left') {
1372
- this.leftSidebar.innerHTML = html;
1373
- return ''; // Hide tooltip box
1374
- }
1375
- if (mode === 'right') {
1376
- this.rightSidebar.innerHTML = html;
1377
- return ''; // Hide tooltip box
1378
- }
1379
-
1380
- if (!this.options.databox) {
1381
- return ''; // No tooltip content
1382
- }
1383
-
1384
- // Default to floating if databox exists but position is 'floating' (or unspecified but object exists)
1385
- return `<div style="min-width: 200px;">${html}</div>`;
1386
- };
1387
-
1388
- const option: any = {
1389
- backgroundColor: this.options.backgroundColor,
1390
- animation: false,
1391
- legend: {
1392
- show: false, // Hide default legend as we use tooltip
1393
- },
1394
- tooltip: {
1395
- show: true,
1396
- showContent: !!this.options.databox, // Show content only if databox is present
1397
- trigger: 'axis',
1398
- triggerOn: this.options.databox?.triggerOn ?? 'mousemove', // Control when to show tooltip/crosshair
1399
- axisPointer: { type: 'cross', label: { backgroundColor: '#475569' } },
1400
- backgroundColor: 'rgba(30, 41, 59, 0.9)',
1401
- borderWidth: 1,
1402
- borderColor: '#334155',
1403
- padding: 10,
1404
- textStyle: {
1405
- color: '#fff',
1406
- fontFamily: this.options.fontFamily || 'sans-serif',
1407
- },
1408
- formatter: tooltipFormatter,
1409
- extraCssText: tooltipPos !== 'floating' && tooltipPos !== undefined ? 'display: none !important;' : undefined,
1410
- position: (pos: any, params: any, el: any, elRect: any, size: any) => {
1411
- const mode = this.options.databox?.position;
1412
- if (mode === 'floating') {
1413
- const obj = { top: 10 };
1414
- obj[['left', 'right'][+(pos[0] < size.viewSize[0] / 2)] as keyof typeof obj] = 30;
1415
- return obj;
1416
- }
1417
- return null;
1418
- },
1419
- },
1420
- axisPointer: {
1421
- link: { xAxisIndex: 'all' },
1422
- label: { backgroundColor: '#475569' },
1423
- },
1424
- graphic: graphic,
1425
- grid: layout.grid,
1426
- xAxis: layout.xAxis,
1427
- yAxis: layout.yAxis,
1428
- dataZoom: layout.dataZoom,
1429
- series: [candlestickSeries, ...indicatorSeries, ...drawingSeriesList],
1430
- };
1431
-
1432
- this.chart.setOption(option, true); // true = not merge, replace.
1433
- }
1434
- }
1
+ import * as echarts from 'echarts';
2
+ import { OHLCV, IndicatorPlot, QFChartOptions, Indicator as IndicatorInterface, ChartContext, Plugin } from './types';
3
+ import { Indicator } from './components/Indicator';
4
+ import { LayoutManager } from './components/LayoutManager';
5
+ import { SeriesBuilder } from './components/SeriesBuilder';
6
+ import { GraphicBuilder } from './components/GraphicBuilder';
7
+ import { TooltipFormatter } from './components/TooltipFormatter';
8
+ import { PluginManager } from './components/PluginManager';
9
+ import { DrawingEditor } from './components/DrawingEditor';
10
+ import { EventBus } from './utils/EventBus';
11
+ import { AxisUtils } from './utils/AxisUtils';
12
+
13
+ export class QFChart implements ChartContext {
14
+ private chart: echarts.ECharts;
15
+ private options: QFChartOptions;
16
+ private marketData: OHLCV[] = [];
17
+ private indicators: Map<string, Indicator> = new Map();
18
+ private timeToIndex: Map<number, number> = new Map();
19
+ private pluginManager: PluginManager;
20
+ private drawingEditor: DrawingEditor;
21
+ public events: EventBus = new EventBus();
22
+ private isMainCollapsed: boolean = false;
23
+ private maximizedPaneId: string | null = null;
24
+ private countdownInterval: any = null;
25
+
26
+ private selectedDrawingId: string | null = null; // Track selected drawing
27
+
28
+ // Drawing System
29
+ private drawings: import('./types').DrawingElement[] = [];
30
+
31
+ public coordinateConversion = {
32
+ pixelToData: (point: { x: number; y: number }) => {
33
+ // Find which grid/pane the point is in
34
+ // We iterate through all panes (series indices usually match pane indices for base series)
35
+ // Actually, we need to know how many panes there are.
36
+ // We can use the layout logic or just check grid indices.
37
+ // ECharts instance has getOption().
38
+ const option = this.chart.getOption() as any;
39
+ if (!option || !option.grid) return null;
40
+
41
+ const gridCount = option.grid.length;
42
+ for (let i = 0; i < gridCount; i++) {
43
+ if (this.chart.containPixel({ gridIndex: i }, [point.x, point.y])) {
44
+ // Found the pane
45
+ const p = this.chart.convertFromPixel({ seriesIndex: i }, [point.x, point.y]);
46
+ // Note: convertFromPixel might need seriesIndex or gridIndex depending on setup.
47
+ // Using gridIndex in convertFromPixel is supported in newer ECharts but sometimes tricky.
48
+ // Since we have one base series per pane (candlestick at 0, indicators at 1+),
49
+ // assuming seriesIndex = gridIndex usually works if they are mapped 1:1.
50
+ // Wait, candlestick is series 0. Indicators are subsequent series.
51
+ // Series index != grid index necessarily.
52
+ // BUT we can use { gridIndex: i } for convertFromPixel too!
53
+ const pGrid = this.chart.convertFromPixel({ gridIndex: i }, [point.x, point.y]);
54
+
55
+ if (pGrid) {
56
+ // Store padded coordinates directly (don't subtract offset)
57
+ // This ensures all coordinates are positive and within the valid padded range
58
+ return { timeIndex: Math.round(pGrid[0]), value: pGrid[1], paneIndex: i };
59
+ }
60
+ }
61
+ }
62
+ return null;
63
+ },
64
+ dataToPixel: (point: { timeIndex: number; value: number; paneIndex?: number }) => {
65
+ const paneIdx = point.paneIndex || 0;
66
+ // Coordinates are already in padded space, so use directly
67
+ const p = this.chart.convertToPixel({ gridIndex: paneIdx }, [point.timeIndex, point.value]);
68
+ if (p) {
69
+ return { x: p[0], y: p[1] };
70
+ }
71
+ return null;
72
+ },
73
+ };
74
+
75
+ // Default colors and constants
76
+ private readonly upColor: string = '#00da3c';
77
+ private readonly downColor: string = '#ec0000';
78
+ private readonly defaultPadding = 0.0;
79
+ private padding: number;
80
+ private dataIndexOffset: number = 0; // Offset for phantom padding data
81
+
82
+ // DOM Elements for Layout
83
+ private rootContainer: HTMLElement;
84
+ private layoutContainer: HTMLElement;
85
+ private toolbarContainer: HTMLElement; // New Toolbar
86
+ private leftSidebar: HTMLElement;
87
+ private rightSidebar: HTMLElement;
88
+ private chartContainer: HTMLElement;
89
+
90
+ constructor(container: HTMLElement, options: QFChartOptions = {}) {
91
+ this.rootContainer = container;
92
+ this.options = {
93
+ title: 'Market',
94
+ height: '600px',
95
+ backgroundColor: '#1e293b',
96
+ upColor: '#00da3c',
97
+ downColor: '#ec0000',
98
+ fontColor: '#cbd5e1',
99
+ fontFamily: 'sans-serif',
100
+ padding: 0.01,
101
+ dataZoom: {
102
+ visible: true,
103
+ position: 'top',
104
+ height: 6,
105
+ },
106
+ layout: {
107
+ mainPaneHeight: '50%',
108
+ gap: 13,
109
+ },
110
+ watermark: true,
111
+ ...options,
112
+ };
113
+
114
+ if (this.options.upColor) this.upColor = this.options.upColor;
115
+ if (this.options.downColor) this.downColor = this.options.downColor;
116
+ this.padding = this.options.padding !== undefined ? this.options.padding : this.defaultPadding;
117
+
118
+ if (this.options.height) {
119
+ if (typeof this.options.height === 'number') {
120
+ this.rootContainer.style.height = `${this.options.height}px`;
121
+ } else {
122
+ this.rootContainer.style.height = this.options.height;
123
+ }
124
+ }
125
+
126
+ // Initialize DOM Layout
127
+ this.rootContainer.innerHTML = '';
128
+
129
+ // Layout Container (Flex Row)
130
+ this.layoutContainer = document.createElement('div');
131
+ this.layoutContainer.style.display = 'flex';
132
+ this.layoutContainer.style.width = '100%';
133
+ this.layoutContainer.style.height = '100%';
134
+ this.layoutContainer.style.overflow = 'hidden';
135
+ this.rootContainer.appendChild(this.layoutContainer);
136
+
137
+ // Left Sidebar
138
+ this.leftSidebar = document.createElement('div');
139
+ this.leftSidebar.style.display = 'none';
140
+ this.leftSidebar.style.width = '250px'; // Default width
141
+ this.leftSidebar.style.flexShrink = '0';
142
+ this.leftSidebar.style.overflowY = 'auto';
143
+ this.leftSidebar.style.backgroundColor = this.options.backgroundColor || '#1e293b';
144
+ this.leftSidebar.style.borderRight = '1px solid #334155';
145
+ this.leftSidebar.style.padding = '10px';
146
+ this.leftSidebar.style.boxSizing = 'border-box';
147
+ this.leftSidebar.style.color = '#cbd5e1';
148
+ this.leftSidebar.style.fontSize = '12px';
149
+ this.leftSidebar.style.fontFamily = this.options.fontFamily || 'sans-serif';
150
+ this.layoutContainer.appendChild(this.leftSidebar);
151
+
152
+ // Toolbar Container
153
+ this.toolbarContainer = document.createElement('div');
154
+ this.layoutContainer.appendChild(this.toolbarContainer);
155
+
156
+ // Chart Container
157
+ this.chartContainer = document.createElement('div');
158
+ this.chartContainer.style.flexGrow = '1';
159
+ this.chartContainer.style.height = '100%';
160
+ this.chartContainer.style.overflow = 'hidden';
161
+ this.layoutContainer.appendChild(this.chartContainer);
162
+
163
+ // Right Sidebar
164
+ this.rightSidebar = document.createElement('div');
165
+ this.rightSidebar.style.display = 'none';
166
+ this.rightSidebar.style.width = '250px';
167
+ this.rightSidebar.style.flexShrink = '0';
168
+ this.rightSidebar.style.overflowY = 'auto';
169
+ this.rightSidebar.style.backgroundColor = this.options.backgroundColor || '#1e293b';
170
+ this.rightSidebar.style.borderLeft = '1px solid #334155';
171
+ this.rightSidebar.style.padding = '10px';
172
+ this.rightSidebar.style.boxSizing = 'border-box';
173
+ this.rightSidebar.style.color = '#cbd5e1';
174
+ this.rightSidebar.style.fontSize = '12px';
175
+ this.rightSidebar.style.fontFamily = this.options.fontFamily || 'sans-serif';
176
+ this.layoutContainer.appendChild(this.rightSidebar);
177
+
178
+ this.chart = echarts.init(this.chartContainer);
179
+ this.pluginManager = new PluginManager(this, this.toolbarContainer);
180
+ this.drawingEditor = new DrawingEditor(this);
181
+
182
+ // Bind global chart/ZRender events to the EventBus
183
+ this.chart.on('dataZoom', (params: any) => {
184
+ this.events.emit('chart:dataZoom', params);
185
+
186
+ // Auto-hide tooltip when dragging chart if triggerOn is 'click' and position is 'floating'
187
+ const triggerOn = this.options.databox?.triggerOn;
188
+ const position = this.options.databox?.position;
189
+ if (triggerOn === 'click' && position === 'floating') {
190
+ // Hide tooltip by dispatching a hideTooltip action
191
+ this.chart.dispatchAction({
192
+ type: 'hideTip',
193
+ });
194
+ }
195
+ });
196
+ // @ts-ignore - ECharts event handler type mismatch
197
+ this.chart.on('finished', (params: any) => this.events.emit('chart:updated', params)); // General chart update
198
+ // @ts-ignore - ECharts ZRender event handler type mismatch
199
+ this.chart.getZr().on('mousedown', (params: any) => this.events.emit('mouse:down', params));
200
+ // @ts-ignore - ECharts ZRender event handler type mismatch
201
+ this.chart.getZr().on('mousemove', (params: any) => this.events.emit('mouse:move', params));
202
+ // @ts-ignore - ECharts ZRender event handler type mismatch
203
+ this.chart.getZr().on('mouseup', (params: any) => this.events.emit('mouse:up', params));
204
+ // @ts-ignore - ECharts ZRender event handler type mismatch
205
+ this.chart.getZr().on('click', (params: any) => this.events.emit('mouse:click', params));
206
+
207
+ const zr = this.chart.getZr();
208
+ const originalSetCursorStyle = zr.setCursorStyle;
209
+ zr.setCursorStyle = function (cursorStyle: string) {
210
+ // Change 'grab' (default roam cursor) to 'crosshair' (more suitable for candlestick chart)
211
+ if (cursorStyle === 'grab') {
212
+ cursorStyle = 'crosshair';
213
+ }
214
+ // Call the original method with your modified style
215
+ originalSetCursorStyle.call(this, cursorStyle);
216
+ };
217
+
218
+ // Bind Drawing Events
219
+ this.bindDrawingEvents();
220
+
221
+ window.addEventListener('resize', this.resize.bind(this));
222
+
223
+ // Listen for fullscreen change to restore state if exited via ESC
224
+ document.addEventListener('fullscreenchange', this.onFullscreenChange);
225
+
226
+ // Keyboard listener for deletion
227
+ document.addEventListener('keydown', this.onKeyDown);
228
+ }
229
+
230
+ private onKeyDown = (e: KeyboardEvent) => {
231
+ if ((e.key === 'Delete' || e.key === 'Backspace') && this.selectedDrawingId) {
232
+ this.removeDrawing(this.selectedDrawingId);
233
+ this.selectedDrawingId = null;
234
+ this.render();
235
+ // Optional: emit deleted event here or in removeDrawing?
236
+ // Since removeDrawing is generic, maybe better here if we want 'deleted by user' nuance.
237
+ // But removeDrawing is called from other places too.
238
+ }
239
+ };
240
+
241
+ private onFullscreenChange = () => {
242
+ this.render();
243
+ };
244
+
245
+ private bindDrawingEvents() {
246
+ let hideTimeout: any = null;
247
+ let lastHoveredGroup: any = null;
248
+
249
+ // Helper to get drawing info
250
+ const getDrawingInfo = (params: any) => {
251
+ if (!params || params.componentType !== 'series' || !params.seriesName?.startsWith('drawings')) {
252
+ return null;
253
+ }
254
+
255
+ // Find the drawing
256
+ const paneIndex = params.seriesIndex; // We can't rely on seriesIndex to find pane index easily as it shifts.
257
+ // But we named the series "drawings-pane-{index}".
258
+ const match = params.seriesName.match(/drawings-pane-(\d+)/);
259
+ if (!match) return null;
260
+
261
+ const paneIdx = parseInt(match[1]);
262
+
263
+ // We stored drawings for this pane in render(), but here we access the flat list?
264
+ // Wait, params.dataIndex is the index in the filtered array passed to that series.
265
+ // We need to re-find the drawing or store metadata.
266
+ // In render(), we map `drawingsByPane`.
267
+
268
+ // Efficient way: Re-filter to get the specific drawing.
269
+ // Assuming the order in render() is preserved.
270
+ const paneDrawings = this.drawings.filter((d) => (d.paneIndex || 0) === paneIdx);
271
+ const drawing = paneDrawings[params.dataIndex];
272
+
273
+ if (!drawing) return null;
274
+
275
+ // Check target for specific part (line or point)
276
+ // ECharts event params.event.target is the graphic element
277
+ const targetName = params.event?.target?.name; // 'line', 'point-start', 'point-end'
278
+
279
+ return { drawing, targetName, paneIdx };
280
+ };
281
+
282
+ this.chart.on('mouseover', (params: any) => {
283
+ const info = getDrawingInfo(params);
284
+ if (!info) return;
285
+
286
+ // Handle visibility of points
287
+ const group = params.event?.target?.parent;
288
+ if (group) {
289
+ // If the drawing is selected, we don't want hover to mess with opacity
290
+ // However, the user might be hovering a DIFFERENT drawing.
291
+ // Let's check the drawing ID from 'info'.
292
+ const isSelected = info.drawing.id === this.selectedDrawingId;
293
+
294
+ if (hideTimeout) {
295
+ clearTimeout(hideTimeout);
296
+ hideTimeout = null;
297
+ }
298
+
299
+ // Show points if not selected (if selected, they are already visible)
300
+ if (!isSelected) {
301
+ group.children().forEach((child: any) => {
302
+ if (child.name && child.name.startsWith('point')) {
303
+ child.attr('style', { opacity: 1 });
304
+ }
305
+ });
306
+ }
307
+
308
+ // Handle switching groups
309
+ if (lastHoveredGroup && lastHoveredGroup !== group) {
310
+ // Check if last group belongs to the selected drawing?
311
+ // We don't have easy access to the drawing ID of 'lastHoveredGroup' unless we stored it.
312
+ // But we can just iterate and hide points.
313
+ // Wait, if lastHoveredGroup IS the selected drawing, we should NOT hide points.
314
+ // We need to know if lastHoveredGroup corresponds to selected drawing.
315
+ // Storing 'lastHoveredDrawingId' would be better.
316
+ // Simple fix: We rely on the render() logic which sets opacity: 1 for selected.
317
+ // If we manually set opacity: 0 via ZRender attr, it might override the initial render state?
318
+ // Yes, ZRender elements are persistent until re-render.
319
+ // So we must be careful not to hide points of the selected drawing.
320
+ // But we don't know the ID of lastHoveredGroup here easily.
321
+ // Let's modify the hide logic to be safer.
322
+ }
323
+ lastHoveredGroup = group;
324
+ }
325
+
326
+ if (info.targetName === 'line') {
327
+ this.events.emit('drawing:hover', {
328
+ id: info.drawing.id,
329
+ type: info.drawing.type,
330
+ });
331
+ // Set cursor
332
+ this.chart.getZr().setCursorStyle('move');
333
+ } else if (info.targetName?.startsWith('point')) {
334
+ const pointIdx = info.targetName === 'point-start' ? 0 : 1;
335
+ this.events.emit('drawing:point:hover', {
336
+ id: info.drawing.id,
337
+ pointIndex: pointIdx,
338
+ });
339
+ this.chart.getZr().setCursorStyle('pointer');
340
+ }
341
+ });
342
+
343
+ this.chart.on('mouseout', (params: any) => {
344
+ const info = getDrawingInfo(params);
345
+ if (!info) return;
346
+
347
+ // Hide points (with slight delay or check)
348
+ const group = params.event?.target?.parent;
349
+
350
+ // If selected, do not hide points
351
+ if (info.drawing.id === this.selectedDrawingId) {
352
+ // Keep points visible
353
+ return;
354
+ }
355
+
356
+ // Delay hide to allow moving between siblings
357
+ hideTimeout = setTimeout(() => {
358
+ if (group) {
359
+ // Check selection again inside timeout just in case
360
+ if (this.selectedDrawingId === info.drawing.id) return;
361
+
362
+ group.children().forEach((child: any) => {
363
+ if (child.name && child.name.startsWith('point')) {
364
+ child.attr('style', { opacity: 0 });
365
+ }
366
+ });
367
+ }
368
+ if (lastHoveredGroup === group) {
369
+ lastHoveredGroup = null;
370
+ }
371
+ }, 50);
372
+
373
+ if (info.targetName === 'line') {
374
+ this.events.emit('drawing:mouseout', { id: info.drawing.id });
375
+ } else if (info.targetName?.startsWith('point')) {
376
+ const pointIdx = info.targetName === 'point-start' ? 0 : 1;
377
+ this.events.emit('drawing:point:mouseout', {
378
+ id: info.drawing.id,
379
+ pointIndex: pointIdx,
380
+ });
381
+ }
382
+ this.chart.getZr().setCursorStyle('default');
383
+ });
384
+
385
+ this.chart.on('mousedown', (params: any) => {
386
+ const info = getDrawingInfo(params);
387
+ if (!info) return;
388
+
389
+ const event = params.event?.event || params.event;
390
+ const x = event?.offsetX;
391
+ const y = event?.offsetY;
392
+
393
+ if (info.targetName === 'line') {
394
+ this.events.emit('drawing:mousedown', {
395
+ id: info.drawing.id,
396
+ x,
397
+ y,
398
+ });
399
+ } else if (info.targetName?.startsWith('point')) {
400
+ const pointIdx = info.targetName === 'point-start' ? 0 : 1;
401
+ this.events.emit('drawing:point:mousedown', {
402
+ id: info.drawing.id,
403
+ pointIndex: pointIdx,
404
+ x,
405
+ y,
406
+ });
407
+ }
408
+ });
409
+
410
+ this.chart.on('click', (params: any) => {
411
+ const info = getDrawingInfo(params);
412
+ if (!info) return;
413
+
414
+ // Select Drawing logic
415
+ if (this.selectedDrawingId !== info.drawing.id) {
416
+ this.selectedDrawingId = info.drawing.id;
417
+ this.events.emit('drawing:selected', { id: info.drawing.id });
418
+ this.render(); // Re-render to update opacity permanent state
419
+ }
420
+
421
+ if (info.targetName === 'line') {
422
+ this.events.emit('drawing:click', { id: info.drawing.id });
423
+ } else if (info.targetName?.startsWith('point')) {
424
+ const pointIdx = info.targetName === 'point-start' ? 0 : 1;
425
+ this.events.emit('drawing:point:click', {
426
+ id: info.drawing.id,
427
+ pointIndex: pointIdx,
428
+ });
429
+ }
430
+ });
431
+
432
+ // Background click to deselect
433
+ this.chart.getZr().on('click', (params: any) => {
434
+ // If target is undefined or not part of a drawing series we know...
435
+ if (!params.target) {
436
+ if (this.selectedDrawingId) {
437
+ this.events.emit('drawing:deselected', { id: this.selectedDrawingId });
438
+ this.selectedDrawingId = null;
439
+ this.render();
440
+ }
441
+ }
442
+ });
443
+ }
444
+
445
+ // --- Plugin System Integration ---
446
+
447
+ public getChart(): echarts.ECharts {
448
+ return this.chart;
449
+ }
450
+
451
+ public getMarketData(): OHLCV[] {
452
+ return this.marketData;
453
+ }
454
+
455
+ public getTimeToIndex(): Map<number, number> {
456
+ return this.timeToIndex;
457
+ }
458
+
459
+ public getOptions(): QFChartOptions {
460
+ return this.options;
461
+ }
462
+
463
+ public disableTools(): void {
464
+ this.pluginManager.deactivatePlugin();
465
+ }
466
+
467
+ public registerPlugin(plugin: Plugin): void {
468
+ this.pluginManager.register(plugin);
469
+ }
470
+
471
+ // --- Drawing System ---
472
+
473
+ public addDrawing(drawing: import('./types').DrawingElement): void {
474
+ this.drawings.push(drawing);
475
+ this.render(); // Re-render to show new drawing
476
+ }
477
+
478
+ public removeDrawing(id: string): void {
479
+ const index = this.drawings.findIndex((d) => d.id === id);
480
+ if (index !== -1) {
481
+ const drawing = this.drawings[index];
482
+ this.drawings.splice(index, 1);
483
+ this.events.emit('drawing:deleted', { id: drawing.id });
484
+ this.render();
485
+ }
486
+ }
487
+
488
+ public getDrawing(id: string): import('./types').DrawingElement | undefined {
489
+ return this.drawings.find((d) => d.id === id);
490
+ }
491
+
492
+ public updateDrawing(drawing: import('./types').DrawingElement): void {
493
+ const index = this.drawings.findIndex((d) => d.id === drawing.id);
494
+ if (index !== -1) {
495
+ this.drawings[index] = drawing;
496
+ this.render();
497
+ }
498
+ }
499
+
500
+ // --- Interaction Locking ---
501
+
502
+ private isLocked: boolean = false;
503
+ private lockedState: any = null;
504
+
505
+ public lockChart(): void {
506
+ if (this.isLocked) return;
507
+ this.isLocked = true;
508
+
509
+ const option = this.chart.getOption() as any;
510
+
511
+ // Store current state to restore later if needed (though setOption merge handles most)
512
+ // Actually, simply disabling interactions is enough.
513
+
514
+ // We update the option to disable dataZoom and tooltip
515
+ this.chart.setOption({
516
+ dataZoom: option.dataZoom.map((dz: any) => ({ ...dz, disabled: true })),
517
+ tooltip: { show: false }, // Hide tooltip during drag
518
+ // We can also disable series interaction if needed, but custom series is handled by us.
519
+ });
520
+ }
521
+
522
+ public unlockChart(): void {
523
+ if (!this.isLocked) return;
524
+ this.isLocked = false;
525
+
526
+ const option = this.chart.getOption() as any;
527
+
528
+ // Restore interactions
529
+ // We assume dataZoom was enabled before. If not, we might re-enable it wrongly.
530
+ // Ideally we should restore from 'options' or check the previous state.
531
+ // Since 'render' rebuilds everything from 'this.options', we can just call render?
532
+ // But render is expensive.
533
+ // Better: Re-enable based on this.options.
534
+
535
+ // Re-enable dataZoom
536
+ const dzConfig = this.options.dataZoom || {};
537
+ const dzVisible = dzConfig.visible ?? true;
538
+
539
+ // We can map over current option.dataZoom and set disabled: false
540
+ if (option.dataZoom) {
541
+ this.chart.setOption({
542
+ dataZoom: option.dataZoom.map((dz: any) => ({
543
+ ...dz,
544
+ disabled: false,
545
+ })),
546
+ tooltip: { show: true },
547
+ });
548
+ }
549
+ }
550
+
551
+ // --------------------------------
552
+
553
+ public setZoom(start: number, end: number): void {
554
+ this.chart.dispatchAction({
555
+ type: 'dataZoom',
556
+ start,
557
+ end,
558
+ });
559
+ }
560
+
561
+ public setMarketData(data: OHLCV[]): void {
562
+ this.marketData = data;
563
+ this.rebuildTimeIndex();
564
+ this.render();
565
+ }
566
+
567
+ /**
568
+ * Update market data incrementally without full re-render
569
+ * Merges new/updated OHLCV data with existing data by timestamp
570
+ *
571
+ * @param data - Array of OHLCV data to merge
572
+ *
573
+ * @remarks
574
+ * **Performance Optimization**: This method only triggers a chart update if the data array contains
575
+ * new or modified bars. If an empty array is passed, no update occurs (expected behavior).
576
+ *
577
+ * **Usage Pattern for Updating Indicators**:
578
+ * When updating both market data and indicators, follow this order:
579
+ *
580
+ * 1. Update indicator data first using `indicator.updateData(plots)`
581
+ * 2. Then call `chart.updateData(newBars)` with the new/modified market data
582
+ *
583
+ * The chart update will trigger a re-render that includes the updated indicator data.
584
+ *
585
+ * **Important**: If you update indicator data without updating market data (e.g., recalculation
586
+ * with same bars), you must still call `chart.updateData([...])` with at least one bar
587
+ * to trigger the re-render. Calling with an empty array will NOT trigger an update.
588
+ *
589
+ * @example
590
+ * ```typescript
591
+ * // Step 1: Update indicator data
592
+ * macdIndicator.updateData({
593
+ * macd: { data: [{ time: 1234567890, value: 150 }], options: { style: 'line', color: '#2962FF' } }
594
+ * });
595
+ *
596
+ * // Step 2: Update market data (triggers re-render with new indicator data)
597
+ * chart.updateData([
598
+ * { time: 1234567890, open: 100, high: 105, low: 99, close: 103, volume: 1000 }
599
+ * ]);
600
+ * ```
601
+ *
602
+ * @example
603
+ * ```typescript
604
+ * // If only updating existing bar (e.g., real-time tick updates):
605
+ * const lastBar = { ...existingBar, close: newPrice, high: Math.max(existingBar.high, newPrice) };
606
+ * chart.updateData([lastBar]); // Updates by timestamp
607
+ * ```
608
+ */
609
+ public updateData(data: OHLCV[]): void {
610
+ if (data.length === 0) return;
611
+
612
+ // Build a map of existing data by time for O(1) lookups
613
+ const existingTimeMap = new Map<number, OHLCV>();
614
+ this.marketData.forEach((bar) => {
615
+ existingTimeMap.set(bar.time, bar);
616
+ });
617
+
618
+ // Track if we added new data or only updated existing
619
+ let hasNewData = false;
620
+
621
+ // Merge new data
622
+ data.forEach((bar) => {
623
+ if (!existingTimeMap.has(bar.time)) {
624
+ hasNewData = true;
625
+ }
626
+ existingTimeMap.set(bar.time, bar);
627
+ });
628
+
629
+ // Rebuild marketData array sorted by time
630
+ this.marketData = Array.from(existingTimeMap.values()).sort((a, b) => a.time - b.time);
631
+
632
+ // Update timeToIndex map
633
+ this.rebuildTimeIndex();
634
+
635
+ // Use pre-calculated padding points from rebuildTimeIndex
636
+ const paddingPoints = this.dataIndexOffset;
637
+
638
+ // Build candlestick data with padding
639
+ const candlestickSeries = SeriesBuilder.buildCandlestickSeries(this.marketData, this.options);
640
+ const emptyCandle = { value: [NaN, NaN, NaN, NaN], itemStyle: { opacity: 0 } };
641
+ const paddedCandlestickData = [
642
+ ...Array(paddingPoints).fill(emptyCandle),
643
+ ...candlestickSeries.data,
644
+ ...Array(paddingPoints).fill(emptyCandle),
645
+ ];
646
+
647
+ // Build category data with padding
648
+ const categoryData = [
649
+ ...Array(paddingPoints).fill(''),
650
+ ...this.marketData.map((k) => new Date(k.time).toLocaleString()),
651
+ ...Array(paddingPoints).fill(''),
652
+ ];
653
+
654
+ // Build indicator series data
655
+ const currentOption = this.chart.getOption() as any;
656
+ const layout = LayoutManager.calculate(
657
+ this.chart.getHeight(),
658
+ this.indicators,
659
+ this.options,
660
+ this.isMainCollapsed,
661
+ this.maximizedPaneId,
662
+ this.marketData
663
+ );
664
+
665
+ // Pass full padded candlestick data for shape positioning
666
+ // But SeriesBuilder expects 'OHLCV[]', while paddedCandlestickData is array of arrays [open,close,low,high]
667
+ // We need to pass the raw marketData but ALIGNED with padding?
668
+ // Or better, pass the processed OHLCV array?
669
+ // Let's pass the raw marketData, but SeriesBuilder needs to handle the padding internally or we pass padded data?
670
+ // SeriesBuilder.buildIndicatorSeries iterates over 'totalDataLength' (which includes padding) and uses 'dataIndexOffset'.
671
+ // So passing 'this.marketData' is not enough because index 0 in marketData corresponds to 'paddingPoints' index in chart.
672
+ // We should pass an array that aligns with chart indices.
673
+ // Let's reconstruct an array of objects {high, low} that includes padding.
674
+
675
+ const paddedOHLCVForShapes = [...Array(paddingPoints).fill(null), ...this.marketData, ...Array(paddingPoints).fill(null)];
676
+
677
+ const { series: indicatorSeries, barColors } = SeriesBuilder.buildIndicatorSeries(
678
+ this.indicators,
679
+ this.timeToIndex,
680
+ layout.paneLayout,
681
+ categoryData.length,
682
+ paddingPoints,
683
+ paddedOHLCVForShapes, // Pass padded OHLCV data
684
+ layout.overlayYAxisMap, // Pass overlay Y-axis mapping
685
+ layout.separatePaneYAxisOffset // Pass Y-axis offset for separate panes
686
+ );
687
+
688
+ // Apply barColors to candlestick data
689
+ const coloredCandlestickData = paddedCandlestickData.map((candle: any, i: number) => {
690
+ if (barColors[i]) {
691
+ return {
692
+ value: candle.value || candle,
693
+ itemStyle: {
694
+ color: barColors[i],
695
+ color0: barColors[i],
696
+ borderColor: barColors[i],
697
+ borderColor0: barColors[i],
698
+ },
699
+ };
700
+ }
701
+ return candle;
702
+ });
703
+
704
+ // Update only the data arrays in the option, not the full config
705
+ const updateOption: any = {
706
+ xAxis: currentOption.xAxis.map((axis: any, index: number) => ({
707
+ data: categoryData,
708
+ })),
709
+ series: [
710
+ {
711
+ data: coloredCandlestickData,
712
+ markLine: candlestickSeries.markLine, // Ensure markLine is updated
713
+ },
714
+ ...indicatorSeries.map((s) => {
715
+ const update: any = { data: s.data };
716
+ // If the series has a renderItem function (custom series like background),
717
+ // we MUST update it because it likely closes over variables (colorArray)
718
+ // from the SeriesBuilder scope which have been recreated.
719
+ if (s.renderItem) {
720
+ update.renderItem = s.renderItem;
721
+ }
722
+ return update;
723
+ }),
724
+ ],
725
+ };
726
+
727
+ // Merge the update (don't replace entire config)
728
+ this.chart.setOption(updateOption, { notMerge: false });
729
+
730
+ // Update countdown if needed
731
+ this.startCountdown();
732
+ }
733
+
734
+ private startCountdown() {
735
+ // Stop existing timer
736
+ this.stopCountdown();
737
+
738
+ if (!this.options.lastPriceLine?.showCountdown || !this.options.interval || this.marketData.length === 0) {
739
+ return;
740
+ }
741
+
742
+ const updateLabel = () => {
743
+ if (this.marketData.length === 0) return;
744
+ const lastBar = this.marketData[this.marketData.length - 1];
745
+ const nextCloseTime = lastBar.time + (this.options.interval || 0);
746
+ const now = Date.now();
747
+ const diff = nextCloseTime - now;
748
+
749
+ if (diff <= 0) {
750
+ // Timer expired (bar closed), maybe wait for next update
751
+ // Or show 00:00:00
752
+ return;
753
+ }
754
+
755
+ // Format time
756
+ const absDiff = Math.abs(diff);
757
+ const hours = Math.floor(absDiff / 3600000);
758
+ const minutes = Math.floor((absDiff % 3600000) / 60000);
759
+ const seconds = Math.floor((absDiff % 60000) / 1000);
760
+
761
+ const timeString = `${hours > 0 ? hours.toString().padStart(2, '0') + ':' : ''}${minutes.toString().padStart(2, '0')}:${seconds
762
+ .toString()
763
+ .padStart(2, '0')}`;
764
+
765
+ // Update markLine label
766
+ // We need to find the candlestick series index (usually 0)
767
+ // But we can update by name if unique, or by index. SeriesBuilder sets name to options.title or 'Market'
768
+ // Safest is to modify the option directly for series index 0 (if that's where candle is)
769
+ // Or better, check current option
770
+ const currentOption = this.chart.getOption() as any;
771
+ if (!currentOption || !currentOption.series) return;
772
+
773
+ // Find candlestick series (type 'candlestick')
774
+ const candleSeriesIndex = currentOption.series.findIndex((s: any) => s.type === 'candlestick');
775
+ if (candleSeriesIndex === -1) return;
776
+
777
+ const candleSeries = currentOption.series[candleSeriesIndex];
778
+ if (!candleSeries.markLine || !candleSeries.markLine.data || !candleSeries.markLine.data[0]) return;
779
+
780
+ const markLineData = candleSeries.markLine.data[0];
781
+ const currentFormatter = markLineData.label.formatter;
782
+
783
+ // We need to preserve the price formatting logic.
784
+ // But formatter is a function in the option we passed, but ECharts might have stored it?
785
+ // ECharts getOption() returns the merged option. Functions are preserved.
786
+ // We can wrap the formatter or just use the price value.
787
+ // markLineData.yAxis is the price.
788
+
789
+ const price = markLineData.yAxis;
790
+ let priceStr = '';
791
+
792
+ // Re-use formatting logic from options if possible, or auto-detect decimals
793
+ if (this.options.yAxisLabelFormatter) {
794
+ priceStr = this.options.yAxisLabelFormatter(price);
795
+ } else {
796
+ const decimals = this.options.yAxisDecimalPlaces !== undefined
797
+ ? this.options.yAxisDecimalPlaces
798
+ : AxisUtils.autoDetectDecimals(this.marketData);
799
+ priceStr = AxisUtils.formatValue(price, decimals);
800
+ }
801
+
802
+ const labelText = `${priceStr}\n${timeString}`;
803
+
804
+ // Reconstruct the markLine data to preserve styles (lineStyle, symbol, etc.)
805
+ // We spread markLineData to keep everything (including lineStyle which defines color),
806
+ // then overwrite the label to update the formatter/text.
807
+
808
+ this.chart.setOption({
809
+ series: [
810
+ {
811
+ name: this.options.title || 'Market',
812
+ markLine: {
813
+ data: [
814
+ {
815
+ ...markLineData, // Preserve lineStyle (color), symbol, yAxis, etc.
816
+ label: {
817
+ ...markLineData.label, // Preserve existing label styles including backgroundColor
818
+ formatter: labelText, // Update only the text
819
+ },
820
+ },
821
+ ],
822
+ },
823
+ },
824
+ ],
825
+ });
826
+ };
827
+
828
+ // Run immediately
829
+ updateLabel();
830
+
831
+ // Start interval
832
+ this.countdownInterval = setInterval(updateLabel, 1000);
833
+ }
834
+
835
+ private stopCountdown() {
836
+ if (this.countdownInterval) {
837
+ clearInterval(this.countdownInterval);
838
+ this.countdownInterval = null;
839
+ }
840
+ }
841
+
842
+ public addIndicator(
843
+ id: string,
844
+ plots: { [name: string]: IndicatorPlot },
845
+ options: {
846
+ overlay?: boolean;
847
+ /** @deprecated Use overlay instead */
848
+ isOverlay?: boolean;
849
+ height?: number;
850
+ titleColor?: string;
851
+ controls?: { collapse?: boolean; maximize?: boolean };
852
+ } = {}
853
+ ): Indicator {
854
+ // Handle backward compatibility: prefer 'overlay' over 'isOverlay'
855
+ const isOverlay = options.overlay !== undefined ? options.overlay : options.isOverlay ?? false;
856
+ let paneIndex = 0;
857
+ if (!isOverlay) {
858
+ // Find the next available pane index
859
+ // Start from 1, as 0 is the main chart
860
+ let maxPaneIndex = 0;
861
+ this.indicators.forEach((ind) => {
862
+ if (ind.paneIndex > maxPaneIndex) {
863
+ maxPaneIndex = ind.paneIndex;
864
+ }
865
+ });
866
+ paneIndex = maxPaneIndex + 1;
867
+ }
868
+
869
+ // Create Indicator object
870
+ const indicator = new Indicator(id, plots, paneIndex, {
871
+ height: options.height,
872
+ collapsed: false,
873
+ titleColor: options.titleColor,
874
+ controls: options.controls,
875
+ });
876
+
877
+ this.indicators.set(id, indicator);
878
+ this.render();
879
+ return indicator;
880
+ }
881
+
882
+ /** @deprecated Use addIndicator instead */
883
+ public setIndicator(id: string, plot: IndicatorPlot, isOverlay: boolean = false): void {
884
+ // Wrap single plot into the new structure (backward compatibility)
885
+ this.addIndicator(id, { [id]: plot }, { overlay: isOverlay });
886
+ }
887
+
888
+ public removeIndicator(id: string): void {
889
+ this.indicators.delete(id);
890
+ this.render();
891
+ }
892
+
893
+ public toggleIndicator(id: string, action: 'collapse' | 'maximize' | 'fullscreen' = 'collapse'): void {
894
+ if (action === 'fullscreen') {
895
+ if (document.fullscreenElement) {
896
+ document.exitFullscreen();
897
+ } else {
898
+ this.rootContainer.requestFullscreen();
899
+ }
900
+ return;
901
+ }
902
+
903
+ if (action === 'maximize') {
904
+ if (this.maximizedPaneId === id) {
905
+ // Restore
906
+ this.maximizedPaneId = null;
907
+ } else {
908
+ // Maximize
909
+ this.maximizedPaneId = id;
910
+ }
911
+ this.render();
912
+ return;
913
+ }
914
+
915
+ if (id === 'main') {
916
+ this.isMainCollapsed = !this.isMainCollapsed;
917
+ this.render();
918
+ return;
919
+ }
920
+ const indicator = this.indicators.get(id);
921
+ if (indicator) {
922
+ indicator.toggleCollapse();
923
+ this.render();
924
+ }
925
+ }
926
+
927
+ public resize(): void {
928
+ this.chart.resize();
929
+ }
930
+
931
+ public destroy(): void {
932
+ this.stopCountdown();
933
+ window.removeEventListener('resize', this.resize.bind(this));
934
+ document.removeEventListener('fullscreenchange', this.onFullscreenChange);
935
+ document.removeEventListener('keydown', this.onKeyDown);
936
+ this.pluginManager.deactivatePlugin(); // Cleanup active tool
937
+ this.pluginManager.destroy(); // Cleanup tooltips
938
+ this.chart.dispose();
939
+ }
940
+
941
+ private rebuildTimeIndex(): void {
942
+ this.timeToIndex.clear();
943
+ this.marketData.forEach((k, index) => {
944
+ this.timeToIndex.set(k.time, index);
945
+ });
946
+
947
+ // Update dataIndexOffset whenever data changes
948
+ const dataLength = this.marketData.length;
949
+ const paddingPoints = Math.ceil(dataLength * this.padding);
950
+ this.dataIndexOffset = paddingPoints;
951
+ }
952
+
953
+ private render(): void {
954
+ if (this.marketData.length === 0) return;
955
+
956
+ // Capture current zoom state before rebuilding options
957
+ let currentZoomState: { start: number; end: number } | null = null;
958
+ try {
959
+ const currentOption = this.chart.getOption() as any;
960
+ if (currentOption && currentOption.dataZoom && currentOption.dataZoom.length > 0) {
961
+ // Find the slider or inside zoom component that controls the x-axis
962
+ const zoomComponent = currentOption.dataZoom.find((dz: any) => dz.type === 'slider' || dz.type === 'inside');
963
+ if (zoomComponent) {
964
+ currentZoomState = {
965
+ start: zoomComponent.start,
966
+ end: zoomComponent.end,
967
+ };
968
+ }
969
+ }
970
+ } catch (e) {
971
+ // Chart might not be initialized yet
972
+ }
973
+
974
+ // --- Sidebar Layout Management ---
975
+ const tooltipPos = this.options.databox?.position; // undefined if not present
976
+ const prevLeftDisplay = this.leftSidebar.style.display;
977
+ const prevRightDisplay = this.rightSidebar.style.display;
978
+
979
+ // If tooltipPos is undefined, we hide both sidebars and don't use them for tooltips.
980
+ // We only show sidebars if position is explicitly 'left' or 'right'.
981
+
982
+ const newLeftDisplay = tooltipPos === 'left' ? 'block' : 'none';
983
+ const newRightDisplay = tooltipPos === 'right' ? 'block' : 'none';
984
+
985
+ // Only resize if visibility changed to avoid unnecessary reflows/resizes
986
+ if (prevLeftDisplay !== newLeftDisplay || prevRightDisplay !== newRightDisplay) {
987
+ this.leftSidebar.style.display = newLeftDisplay;
988
+ this.rightSidebar.style.display = newRightDisplay;
989
+ this.chart.resize();
990
+ }
991
+ // ---------------------------------
992
+
993
+ // Use pre-calculated padding points from rebuildTimeIndex
994
+ const paddingPoints = this.dataIndexOffset;
995
+
996
+ // Create extended category data with empty labels for padding
997
+ const categoryData = [
998
+ ...Array(paddingPoints).fill(''), // Left padding
999
+ ...this.marketData.map((k) => new Date(k.time).toLocaleString()),
1000
+ ...Array(paddingPoints).fill(''), // Right padding
1001
+ ];
1002
+
1003
+ // 1. Calculate Layout
1004
+ const layout = LayoutManager.calculate(
1005
+ this.chart.getHeight(),
1006
+ this.indicators,
1007
+ this.options,
1008
+ this.isMainCollapsed,
1009
+ this.maximizedPaneId,
1010
+ this.marketData
1011
+ );
1012
+
1013
+ // Convert user-provided dataZoom start/end to account for padding
1014
+ // User's start/end refer to real data (0% = start of real data, 100% = end of real data)
1015
+ // We need to convert to padded data coordinates
1016
+ if (!currentZoomState && layout.dataZoom && this.marketData.length > 0) {
1017
+ const realDataLength = this.marketData.length;
1018
+ const totalLength = categoryData.length; // includes padding on both sides
1019
+ const paddingRatio = paddingPoints / totalLength;
1020
+ const dataRatio = realDataLength / totalLength;
1021
+
1022
+ layout.dataZoom.forEach((dz) => {
1023
+ // Convert user's start/end (0-100 referring to real data) to actual start/end (0-100 referring to padded data)
1024
+ if (dz.start !== undefined) {
1025
+ // User's start% of real data -> actual position in padded data
1026
+ const userStartFraction = dz.start / 100;
1027
+ const actualStartFraction = paddingRatio + userStartFraction * dataRatio;
1028
+ dz.start = actualStartFraction * 100;
1029
+ }
1030
+ if (dz.end !== undefined) {
1031
+ // User's end% of real data -> actual position in padded data
1032
+ const userEndFraction = dz.end / 100;
1033
+ const actualEndFraction = paddingRatio + userEndFraction * dataRatio;
1034
+ dz.end = actualEndFraction * 100;
1035
+ }
1036
+ });
1037
+ }
1038
+
1039
+ // Apply preserved zoom state if available (this overrides the conversion above)
1040
+ if (currentZoomState && layout.dataZoom) {
1041
+ layout.dataZoom.forEach((dz) => {
1042
+ dz.start = currentZoomState!.start;
1043
+ dz.end = currentZoomState!.end;
1044
+ });
1045
+ }
1046
+
1047
+ // Patch X-Axis with extended data
1048
+ layout.xAxis.forEach((axis) => {
1049
+ axis.data = categoryData;
1050
+ axis.boundaryGap = false; // No additional gap needed, we have phantom data
1051
+ });
1052
+
1053
+ // 2. Build Series with phantom data padding
1054
+ const candlestickSeries = SeriesBuilder.buildCandlestickSeries(this.marketData, this.options);
1055
+ // Extend candlestick data with empty objects (not null) to avoid rendering errors
1056
+ const emptyCandle = { value: [NaN, NaN, NaN, NaN], itemStyle: { opacity: 0 } };
1057
+ candlestickSeries.data = [...Array(paddingPoints).fill(emptyCandle), ...candlestickSeries.data, ...Array(paddingPoints).fill(emptyCandle)];
1058
+
1059
+ // Build array of OHLCV aligned with indices for shape positioning
1060
+ const paddedOHLCVForShapes = [...Array(paddingPoints).fill(null), ...this.marketData, ...Array(paddingPoints).fill(null)];
1061
+
1062
+ const { series: indicatorSeries, barColors } = SeriesBuilder.buildIndicatorSeries(
1063
+ this.indicators,
1064
+ this.timeToIndex,
1065
+ layout.paneLayout,
1066
+ categoryData.length,
1067
+ paddingPoints,
1068
+ paddedOHLCVForShapes, // Pass padded OHLCV
1069
+ layout.overlayYAxisMap, // Pass overlay Y-axis mapping
1070
+ layout.separatePaneYAxisOffset // Pass Y-axis offset for separate panes
1071
+ );
1072
+
1073
+ // Apply barColors to candlestick data
1074
+ candlestickSeries.data = candlestickSeries.data.map((candle: any, i: number) => {
1075
+ if (barColors[i]) {
1076
+ return {
1077
+ value: candle.value || candle,
1078
+ itemStyle: {
1079
+ color: barColors[i],
1080
+ color0: barColors[i],
1081
+ borderColor: barColors[i],
1082
+ borderColor0: barColors[i],
1083
+ },
1084
+ };
1085
+ }
1086
+ return candle;
1087
+ });
1088
+
1089
+ // 3. Build Graphics
1090
+ const graphic = GraphicBuilder.build(layout, this.options, this.toggleIndicator.bind(this), this.isMainCollapsed, this.maximizedPaneId);
1091
+
1092
+ // 4. Build Drawings Series (One Custom Series per Pane used)
1093
+ const drawingsByPane = new Map<number, import('./types').DrawingElement[]>();
1094
+ this.drawings.forEach((d) => {
1095
+ const paneIdx = d.paneIndex || 0;
1096
+ if (!drawingsByPane.has(paneIdx)) {
1097
+ drawingsByPane.set(paneIdx, []);
1098
+ }
1099
+ drawingsByPane.get(paneIdx)!.push(d);
1100
+ });
1101
+
1102
+ const drawingSeriesList: any[] = [];
1103
+ drawingsByPane.forEach((drawings, paneIndex) => {
1104
+ drawingSeriesList.push({
1105
+ type: 'custom',
1106
+ name: `drawings-pane-${paneIndex}`,
1107
+ xAxisIndex: paneIndex,
1108
+ yAxisIndex: paneIndex,
1109
+ clip: true,
1110
+ renderItem: (params: any, api: any) => {
1111
+ const drawing = drawings[params.dataIndex];
1112
+ if (!drawing) return;
1113
+
1114
+ const start = drawing.points[0];
1115
+ const end = drawing.points[1];
1116
+
1117
+ if (!start || !end) return;
1118
+
1119
+ // Coordinates are already in padded space, use directly
1120
+ const p1 = api.coord([start.timeIndex, start.value]);
1121
+ const p2 = api.coord([end.timeIndex, end.value]);
1122
+
1123
+ const isSelected = drawing.id === this.selectedDrawingId;
1124
+
1125
+ if (drawing.type === 'line') {
1126
+ return {
1127
+ type: 'group',
1128
+ children: [
1129
+ {
1130
+ type: 'line',
1131
+ name: 'line',
1132
+ shape: {
1133
+ x1: p1[0],
1134
+ y1: p1[1],
1135
+ x2: p2[0],
1136
+ y2: p2[1],
1137
+ },
1138
+ style: {
1139
+ stroke: drawing.style?.color || '#3b82f6',
1140
+ lineWidth: drawing.style?.lineWidth || 2,
1141
+ },
1142
+ },
1143
+ {
1144
+ type: 'circle',
1145
+ name: 'point-start',
1146
+ shape: { cx: p1[0], cy: p1[1], r: 4 },
1147
+ style: {
1148
+ fill: '#fff',
1149
+ stroke: drawing.style?.color || '#3b82f6',
1150
+ lineWidth: 1,
1151
+ opacity: isSelected ? 1 : 0, // Show if selected
1152
+ },
1153
+ },
1154
+ {
1155
+ type: 'circle',
1156
+ name: 'point-end',
1157
+ shape: { cx: p2[0], cy: p2[1], r: 4 },
1158
+ style: {
1159
+ fill: '#fff',
1160
+ stroke: drawing.style?.color || '#3b82f6',
1161
+ lineWidth: 1,
1162
+ opacity: isSelected ? 1 : 0, // Show if selected
1163
+ },
1164
+ },
1165
+ ],
1166
+ };
1167
+ } else if (drawing.type === 'fibonacci') {
1168
+ const x1 = p1[0];
1169
+ const y1 = p1[1];
1170
+ const x2 = p2[0];
1171
+ const y2 = p2[1];
1172
+
1173
+ const startX = Math.min(x1, x2);
1174
+ const endX = Math.max(x1, x2);
1175
+ const width = endX - startX;
1176
+ const diffY = y2 - y1;
1177
+
1178
+ const levels = [0, 0.236, 0.382, 0.5, 0.618, 0.786, 1];
1179
+ const colors = ['#787b86', '#f44336', '#ff9800', '#4caf50', '#2196f3', '#00bcd4', '#787b86'];
1180
+
1181
+ const children: any[] = [];
1182
+
1183
+ // 1. Diagonal Line
1184
+ children.push({
1185
+ type: 'line',
1186
+ name: 'line', // Use 'line' name to enable dragging logic in DrawingEditor
1187
+ shape: { x1, y1, x2, y2 },
1188
+ style: {
1189
+ stroke: '#999',
1190
+ lineWidth: 1,
1191
+ lineDash: [4, 4],
1192
+ },
1193
+ });
1194
+
1195
+ // 2. Control Points (invisible by default)
1196
+ children.push({
1197
+ type: 'circle',
1198
+ name: 'point-start',
1199
+ shape: { cx: x1, cy: y1, r: 4 },
1200
+ style: {
1201
+ fill: '#fff',
1202
+ stroke: drawing.style?.color || '#3b82f6',
1203
+ lineWidth: 1,
1204
+ opacity: isSelected ? 1 : 0,
1205
+ },
1206
+ z: 100, // Ensure on top
1207
+ });
1208
+ children.push({
1209
+ type: 'circle',
1210
+ name: 'point-end',
1211
+ shape: { cx: x2, cy: y2, r: 4 },
1212
+ style: {
1213
+ fill: '#fff',
1214
+ stroke: drawing.style?.color || '#3b82f6',
1215
+ lineWidth: 1,
1216
+ opacity: isSelected ? 1 : 0,
1217
+ },
1218
+ z: 100,
1219
+ });
1220
+
1221
+ // 3. Levels and Backgrounds
1222
+ levels.forEach((level, index) => {
1223
+ const levelY = y2 - diffY * level;
1224
+ const color = colors[index % colors.length];
1225
+
1226
+ // Horizontal Line
1227
+ children.push({
1228
+ type: 'line',
1229
+ 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.
1230
+ // The user asked for "fib levels between start and end".
1231
+ shape: { x1: startX, y1: levelY, x2: endX, y2: levelY },
1232
+ style: { stroke: color, lineWidth: 1 },
1233
+ silent: true, // Make internal lines silent so clicks pass to background/diagonal?
1234
+ });
1235
+
1236
+ const startVal = drawing.points[0].value;
1237
+ const endVal = drawing.points[1].value;
1238
+ const valDiff = endVal - startVal;
1239
+ const price = endVal - valDiff * level;
1240
+
1241
+ children.push({
1242
+ type: 'text',
1243
+ style: {
1244
+ text: `${level} (${price.toFixed(2)})`,
1245
+ x: startX + 5,
1246
+ y: levelY - 10,
1247
+ fill: color,
1248
+ fontSize: 10,
1249
+ },
1250
+ silent: true,
1251
+ });
1252
+
1253
+ // Background
1254
+ if (index < levels.length - 1) {
1255
+ const nextLevel = levels[index + 1];
1256
+ const nextY = y2 - diffY * nextLevel;
1257
+ const rectH = Math.abs(nextY - levelY);
1258
+ const rectY = Math.min(levelY, nextY);
1259
+
1260
+ children.push({
1261
+ type: 'rect',
1262
+ shape: { x: startX, y: rectY, width, height: rectH },
1263
+ style: {
1264
+ fill: colors[(index + 1) % colors.length],
1265
+ opacity: 0.1,
1266
+ },
1267
+ silent: true, // Let clicks pass through?
1268
+ });
1269
+ }
1270
+ });
1271
+
1272
+ const backgrounds: any[] = [];
1273
+ const linesAndText: any[] = [];
1274
+
1275
+ levels.forEach((level, index) => {
1276
+ const levelY = y2 - diffY * level;
1277
+ const color = colors[index % colors.length];
1278
+
1279
+ linesAndText.push({
1280
+ type: 'line',
1281
+ shape: { x1: startX, y1: levelY, x2: endX, y2: levelY },
1282
+ style: { stroke: color, lineWidth: 1 },
1283
+ silent: true,
1284
+ });
1285
+
1286
+ const startVal = drawing.points[0].value;
1287
+ const endVal = drawing.points[1].value;
1288
+ const valDiff = endVal - startVal;
1289
+ const price = endVal - valDiff * level;
1290
+
1291
+ linesAndText.push({
1292
+ type: 'text',
1293
+ style: {
1294
+ text: `${level} (${price.toFixed(2)})`,
1295
+ x: startX + 5,
1296
+ y: levelY - 10,
1297
+ fill: color,
1298
+ fontSize: 10,
1299
+ },
1300
+ silent: true,
1301
+ });
1302
+
1303
+ if (index < levels.length - 1) {
1304
+ const nextLevel = levels[index + 1];
1305
+ const nextY = y2 - diffY * nextLevel;
1306
+ const rectH = Math.abs(nextY - levelY);
1307
+ const rectY = Math.min(levelY, nextY);
1308
+
1309
+ backgrounds.push({
1310
+ type: 'rect',
1311
+ name: 'line', // Enable dragging by clicking background!
1312
+ shape: { x: startX, y: rectY, width, height: rectH },
1313
+ style: {
1314
+ fill: colors[(index + 1) % colors.length],
1315
+ opacity: 0.1,
1316
+ },
1317
+ });
1318
+ }
1319
+ });
1320
+
1321
+ return {
1322
+ type: 'group',
1323
+ children: [
1324
+ ...backgrounds,
1325
+ ...linesAndText,
1326
+ {
1327
+ type: 'line',
1328
+ name: 'line',
1329
+ shape: { x1, y1, x2, y2 },
1330
+ style: { stroke: '#999', lineWidth: 1, lineDash: [4, 4] },
1331
+ },
1332
+ {
1333
+ type: 'circle',
1334
+ name: 'point-start',
1335
+ shape: { cx: x1, cy: y1, r: 4 },
1336
+ style: {
1337
+ fill: '#fff',
1338
+ stroke: drawing.style?.color || '#3b82f6',
1339
+ lineWidth: 1,
1340
+ opacity: isSelected ? 1 : 0,
1341
+ },
1342
+ z: 100,
1343
+ },
1344
+ {
1345
+ type: 'circle',
1346
+ name: 'point-end',
1347
+ shape: { cx: x2, cy: y2, r: 4 },
1348
+ style: {
1349
+ fill: '#fff',
1350
+ stroke: drawing.style?.color || '#3b82f6',
1351
+ lineWidth: 1,
1352
+ opacity: isSelected ? 1 : 0,
1353
+ },
1354
+ z: 100,
1355
+ },
1356
+ ],
1357
+ };
1358
+ }
1359
+ },
1360
+ data: drawings.map((d) => [d.points[0].timeIndex, d.points[0].value, d.points[1].timeIndex, d.points[1].value]),
1361
+ z: 100,
1362
+ silent: false,
1363
+ });
1364
+ });
1365
+
1366
+ // 5. Tooltip Formatter
1367
+ const tooltipFormatter = (params: any[]) => {
1368
+ const html = TooltipFormatter.format(params, this.options);
1369
+ const mode = this.options.databox?.position; // undefined if not present
1370
+
1371
+ if (mode === 'left') {
1372
+ this.leftSidebar.innerHTML = html;
1373
+ return ''; // Hide tooltip box
1374
+ }
1375
+ if (mode === 'right') {
1376
+ this.rightSidebar.innerHTML = html;
1377
+ return ''; // Hide tooltip box
1378
+ }
1379
+
1380
+ if (!this.options.databox) {
1381
+ return ''; // No tooltip content
1382
+ }
1383
+
1384
+ // Default to floating if databox exists but position is 'floating' (or unspecified but object exists)
1385
+ return `<div style="min-width: 200px;">${html}</div>`;
1386
+ };
1387
+
1388
+ const option: any = {
1389
+ backgroundColor: this.options.backgroundColor,
1390
+ animation: false,
1391
+ legend: {
1392
+ show: false, // Hide default legend as we use tooltip
1393
+ },
1394
+ tooltip: {
1395
+ show: true,
1396
+ showContent: !!this.options.databox, // Show content only if databox is present
1397
+ trigger: 'axis',
1398
+ triggerOn: this.options.databox?.triggerOn ?? 'mousemove', // Control when to show tooltip/crosshair
1399
+ axisPointer: { type: 'cross', label: { backgroundColor: '#475569' } },
1400
+ backgroundColor: 'rgba(30, 41, 59, 0.9)',
1401
+ borderWidth: 1,
1402
+ borderColor: '#334155',
1403
+ padding: 10,
1404
+ textStyle: {
1405
+ color: '#fff',
1406
+ fontFamily: this.options.fontFamily || 'sans-serif',
1407
+ },
1408
+ formatter: tooltipFormatter,
1409
+ extraCssText: tooltipPos !== 'floating' && tooltipPos !== undefined ? 'display: none !important;' : undefined,
1410
+ position: (pos: any, params: any, el: any, elRect: any, size: any) => {
1411
+ const mode = this.options.databox?.position;
1412
+ if (mode === 'floating') {
1413
+ const obj = { top: 10 };
1414
+ obj[['left', 'right'][+(pos[0] < size.viewSize[0] / 2)] as keyof typeof obj] = 30;
1415
+ return obj;
1416
+ }
1417
+ return null;
1418
+ },
1419
+ },
1420
+ axisPointer: {
1421
+ link: { xAxisIndex: 'all' },
1422
+ label: { backgroundColor: '#475569' },
1423
+ },
1424
+ graphic: graphic,
1425
+ grid: layout.grid,
1426
+ xAxis: layout.xAxis,
1427
+ yAxis: layout.yAxis,
1428
+ dataZoom: layout.dataZoom,
1429
+ series: [candlestickSeries, ...indicatorSeries, ...drawingSeriesList],
1430
+ };
1431
+
1432
+ this.chart.setOption(option, true); // true = not merge, replace.
1433
+ }
1434
+ }